Files
endorphin-tessera-gui/src/model/binding.rs
T

272 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。
//! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。
//!
//! 调用路径: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::{
collections::HashSet,
sync::{Arc, Mutex},
};
use anyhow::{Result, bail};
use endor_core::{NativeContext, ObjectPayload, ObjectRef, Value, Vm, VmError, WeakObjectRef};
use crate::{block, event_dispatch::Subscription, ui_bridge::attach_desktop_type};
/// The output value is borrowed for equality only, including object handles.
/// It never owns a reference or dereferences a possibly reclaimed cached object.
#[derive(Debug, Clone)]
/// 上次绑定计算的读取观察,用于判断是否需要重新执行源计算。
struct Cached {
// 每项保存读取对象/属性的版本观察;刷新时向对象存储核对,而非复制业务值。
reads: Vec<endor_core::ReadDependency>,
// 只借用上次结果的身份做相等比较;不能解引用其中可能已被回收的对象。
output: Value,
}
#[derive(Debug, Clone)]
/// 托管属性连接:由目标强持有,连接只强持有 compute,不反向保活目标;缓存共享只读计算的观察结果。
pub struct Binding {
// 目标属性名;目标 Block 通过 bindings 列表拥有本连接。
field: String,
// Some 表示连接有效且拥有计算函数;取消/替换后置 None,让 trace 停止保活函数。
pub compute: Option<ObjectRef>,
// payload 克隆共享同一缓存,刷新局部副本也能更新缓存;首次计算前为 None。
reads: Arc<Mutex<Option<Cached>>>,
}
impl ObjectPayload for Binding {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-binding"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// Aster GC 调用 trace 时只追踪有效计算;reads/output 都是借用观察,不能增加长期拥有边。
if let Some(compute) = self.compute {
visit(compute);
}
}
}
/// 检查绑定目标与可调用计算,建立托管连接及其引用关系。
pub fn connect(
cx: &mut NativeContext<'_>,
target: ObjectRef,
field: &str,
compute: ObjectRef,
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
// 在触碰连接列表前拒绝非法名称,尤其禁止绑定运行时类型属性。
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",
));
}
// 刷新统一以零参数调用 compute,因此必须有参数元数据并且接受零实参。
if !cx
.store()
.call_arity(compute)?
.ok_or(VmError::InvalidArgument(
"binding compute requires positional metadata",
))?
.accepts(0)
{
return Err(VmError::InvalidArgument(
"binding compute requires zero unbound parameters",
));
}
// 同一字段只保留一个有效计算。其他字段连接沿用;旧同字段连接失效并清缓存,
// 使外部仍持有的 Subscription 不会继续驱动被替换的计算。
// 收集仍有效且属于其他字段的连接,随后以这份列表替换原 bindings。
let mut retained = vec![];
// 遍历目标拥有的旧连接,已失效项不再保留;同字段有效项必须取消以保证单字段单计算。
for connection in &data.bindings {
// 克隆 payload 以修改 compute,缓存 Arc 仍共享原连接的观察状态。
let mut binding = cx
.store()
.payload::<Binding>(*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
.lock()
.map_err(|_| VmError::InvalidArgument("binding cache poisoned"))? = None;
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),
reads: Arc::new(Mutex::new(None)),
})?;
retained.push(connection);
data.bindings = retained;
// text 改由通用绑定负责后,清除旧 textOf 推导入口,避免两套机制竞争同一属性。
if field == "text" {
data.text_binding = None;
}
cx.replace_payload(reference, data)?;
// 返回的取消令牌仅弱引用连接;连接生命期由目标 Block 的 bindings 决定。
let token = cx.allocate(Subscription {
connection: cx.store().downgrade(connection)?,
})?;
attach_desktop_type(cx, token, "Subscription", desktop)?;
Ok(token)
}
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
/// UiBridge::refresh_bindings 在每轮展示刷新开始时调用;compute 只负责返回值,不能直接写已有模型。
/// 本函数退出只读调用后才验证并写目标属性,这些宿主写入可以使其他绑定失效,因而按完整轮次求固定点。
pub fn refresh(
vm: &mut Vm,
nodes: &[WeakObjectRef],
host_desktops: &HashSet<ObjectRef>,
) -> Result<usize> {
// 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::<Binding>(connection, "Binding")?.clone();
// 断开或被替换的连接不再执行,即使外部仍持有旧 Subscription。
let Some(compute) = binding.compute else {
continue;
};
// 锁只用于复制缓存,源 compute 执行期间不持锁,避免执行源代码时占着宿主缓存互斥量。
let cached = binding
.reads
.lock()
.map_err(|_| anyhow::anyhow!("binding cache poisoned"))?
.clone();
// 两项都成立才能跳过:读取依赖版本未变,且目标仍等于上次输出。
// 后一项让外部直接改写目标后,绑定仍能恢复其计算值。
if cached.as_ref().is_some_and(|cached| {
cached
.reads
.iter()
.all(|read| vm.object_store().read_dependency_valid(read))
&& vm
.get_attr(target, &binding.field)
.is_ok_and(|value| value == cached.output)
}) {
continue;
}
// 防止相互写入的绑定持续振荡;预算跨轮累计,超限向宿主报告不稳定。
work += 1;
// 已评估次数跨轮累计,超过预算就报告不收敛,而非丢弃剩余绑定继续发布。
if work > 10_000 {
bail!("property bindings did not stabilize within the execution budget");
}
evaluated = true;
// 计算阶段禁止写入,精确记录本次实际读取;单次调用另设帧数与指令上限。
let (value, reads) = vm
.call_read_only_precise_with_limits(
compute,
&[],
endor_core::ExecutionLimits {
max_frames: 128,
max_instructions: 100_000,
},
)
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
// 暂存应用阶段错误,让下方 release_value 无论校验/写入是否成功都释放计算返回值。
let applied: Result<()> = (|| {
// 宿主桌面对象不纳入此缓存依赖;保留业务读取,避免宿主发布状态驱动重算。
let reads = reads
.dependencies
.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()
.map_err(|_| anyhow::anyhow!("binding cache poisoned"))? =
Some(Cached {
reads,
output: value.clone(),
});
Ok(())
})();
// 缓存不拥有输出对象;释放调用返回值后再传播应用错误,避免失败路径漏引用。
vm.release_value(value)?;
applied?;
}
Ok(())
})();
// 无论 result 成功还是失败,先释放弱升级带来的临时强引用,再传播错误。
vm.release(target)?;
result?;
}
// 整轮无需计算才达到固定点;即使某次计算输出未变,也再扫描一轮确认依赖有效。
if !evaluated {
return Ok(work);
}
}
}