feat: retain live page mounts and update shared UI references across aliases
Verify Tessera GUI / verify (push) Failing after 7m13s

This commit is contained in:
2026-10-20 20:58:41 +08:00
parent 9d3f766275
commit 757fa22d9b
8 changed files with 528 additions and 95 deletions
+10 -6
View File
@@ -7,7 +7,8 @@
| activity.rs | Bounded in-memory HTTP exchanges with marked previews and allowlisted request headers |
| endor_host.rs | Aster compilation, fresh PUT candidates, worker-owned live REPL VM, bounded execution, root cleanup and AST developer tree |
| endor_script.rs | Batch complete inputs for Aster's Interactive parser/compiler, retain Module entry, and rebase Aster spans to the original snapshot |
| ui_bridge.rs | Tessera UI/page payloads and bound render methods, preallocated node/page pools and native snapshot extraction |
| ui_bridge.rs | Tessera UI/page payloads and bound render methods, reusable node/page pools, explicit native-edge VM roots, live mounts/unmount and native frame extraction |
| live_ui.rs | Stable native UI references, alias-preserving replacement, cycle/depth checks and immutable frame extraction |
| layout.rs | Native UI tree to Frame; expanded-tree and coordinate budgets |
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation and display commands |
| app.rs | Window events, redraw and system widget pointer/keyboard/IME routing |
@@ -30,9 +31,10 @@ to execute Endor in the application.
A complete-PUT execution candidate owns all external VM roots, including the 4096-node pool
and 64 pages with bound render callbacks. Script units share one globals dictionary
inside the candidate; no variables or VM handles persist across successful PUTs.
Callbacks return borrowed, preallocated real VM objects. Their mutable slots hold
immutable native UI descriptions; child edges contain owned native Arcs, never
managed VM references. Page rendering stores native root data in the build session;
Callbacks return borrowed, preallocated real VM objects. UI payloads opt into Aster
shared-reference assignment. Native live child/forward edges contain owned Arcs;
Console registers corresponding external VM roots through retain/release, without
mutable untraced VM edges in payloads. Window frames contain only immutable native data. Page rendering stores native root data in the build session;
the final render selects the snapshot after all script units succeed. Once
extraction finishes, release temporary results, clear
globals, release roots, collect cycles and require an empty VM. The window keeps
@@ -65,10 +67,12 @@ typed actions and keep all execution/source behavior outside the widget layer.
Console sessions use `endor_host::ReplSession` on the same worker thread. Real VM
globals, UI pools and `_` survive between inputs. Parse/compile precede execution;
only a successful input drains a render request and commits a native snapshot.
only a successful input commits a pending mount or publishes changes to an existing mount.
Runtime-failed input can retain earlier completed bindings, but cannot publish its
pending render. Session budgets cover cumulative source/AST and fixed UI pools.
page.unmount releases a mount; unused node/page pool slots are reclaimed between inputs.
Explicit reset drops the VM; accepted PUT/code DELETE change the publication epoch
and invalidate the old context. Source snapshots carry annotated AST ranges for the
actual Console transcript; source history is never rerun to reconstruct memory.
The Aster pin includes independent Assignment AST/name-analysis/bytecode support.
The Aster pin includes independent Assignment syntax and generic opt-in assign_from support.
Live reference tests cover alias updates, nested mounts, cycle rejection, detached branch release and unmount/remount.
+4 -4
View File
@@ -584,10 +584,10 @@ Send PUT bodies as `text/plain; charset=utf-8`.
`POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function
definitions and expressions as text/plain. Consecutive requests use one real VM
and globals dictionary on the execution worker. `title = ui.text("Hello")` creates
an object without displaying it. A later `page.render(root)` publishes its page.
an object without displaying it. A later `page.render(root)` mounts its page. UI references share replacement state: after mounting, assigning a new UI value to a reference updates its aliases and mounted positions. `page.unmount()` releases the mount.
`let title = ...` is also supported. `_` retains the actual last value. No input
history is replayed. Responses include session/input IDs, value, binding count,
rendered flag and revision. Without render the displayed page/revision stay unchanged.
rendered flag and revision. Before the first mount the page/revision stay unchanged; afterwards successful inputs publish changed mounted references automatically.
`DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings,
leaving the displayed native snapshot intact. Parsing/compilation happen before
@@ -595,7 +595,7 @@ execution. On runtime error earlier statements in that input may remain live, bu
its pending render is discarded; previous displayed page is preserved.
Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes,
4096 UI nodes and 64 pages. Reset to reclaim them. No VM handles cross to the window.
4096 retained UI nodes and 64 retained pages. Unused slots are reclaimed between inputs; reset releases the session. No VM handles cross to the window.
A newer accepted complete-source PUT or code DELETE invalidates queued Console
inputs and resets the live context. They retain their existing snapshot semantics.
GET /code returns the source of the displayed snapshot: exact submitted source for
@@ -616,7 +616,7 @@ Positional arguments only:
- `ui.rect(width, height, radius = 0, color = "#DCE8FF")`.
- `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`.
- `ui.spacer(size)`.
- `ui.page()` creates a page; `page.render(root)` sets its root. The last render
- `ui.page()` creates a page; `page.render(root)` mounts its root; `page.unmount()` releases the mount. The last render
in a successful submission selects the page shown in this namespace.
Defaults above describe the host API; do not write keyword arguments in Endor.
+79 -8
View File
@@ -1,5 +1,5 @@
//! Compile complete source in a fresh VM or retain a worker-owned Console VM.
//! Console publishes owned native snapshots only after a successful explicit render.
//! Console retains mounted native references and publishes immutable frames after successful inputs.
use anyhow::{Result, bail};
use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError};
use frontend::{
@@ -257,7 +257,9 @@ impl ReplSession {
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.take_rendered()?;
self.bridge.discard_pending()?;
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
self.inputs += 1;
self.ast_nodes += budget.nodes;
let offset = line_starts(&self.source).len();
@@ -313,7 +315,7 @@ impl ReplSession {
retained?;
rendered_value = repr?;
}
let document = if let Some(root) = self.bridge.take_rendered()? {
let document = if let Some(root) = self.bridge.prepare_frame()? {
let document = RenderDocument {
source: self.source.clone(),
root: Some(CodeNode {
@@ -324,10 +326,15 @@ impl ReplSession {
end_line: self.source.lines().count(),
children: self.nodes.clone(),
}),
frame: layout::frame(&root)?,
ui_root: Some(root),
frame: root
.as_ref()
.map(|root| layout::frame(root))
.transpose()?
.unwrap_or_default(),
ui_root: root.clone(),
};
document.validate()?;
self.bridge.commit_frame(root)?;
Some(document)
} else {
None
@@ -345,12 +352,14 @@ impl ReplSession {
})
})();
if result.is_err() {
self.bridge.take_rendered()?;
self.bridge.discard_pending()?;
if let Some(node) = self.nodes.last_mut() {
node.name
.push_str(" (error; earlier statements may remain live)");
}
}
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
result
}
}
@@ -605,7 +614,69 @@ mod tests {
const PAGE: &str = include_str!("../examples/page.endor");
#[test]
fn live_session_retains_real_objects_and_renders_only_on_request() {
fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() {
let mut session = ReplSession::new(10).unwrap();
session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap();
let identity = session.execute("other", "app.main").unwrap().value;
let updated = session
.execute("card = ui.rect(640, 200, 32)", "app.main")
.unwrap();
assert_eq!(updated.value, identity);
assert_eq!(
session.execute("other", "app.main").unwrap().value,
identity
);
let frame = updated.document.unwrap().frame;
assert!(matches!(
frame.commands[0],
RenderCommand::RoundedRect {
width: 640.0,
height: 200.0,
..
}
));
assert!(
session
.execute("card = ui.column(0, 0, other)", "app.main")
.is_err()
);
assert!(
session
.execute("other", "app.main")
.unwrap()
.document
.is_none()
);
let unmounted = session
.execute("page.unmount()", "app.main")
.unwrap()
.document
.unwrap();
assert!(unmounted.frame.commands.is_empty());
assert!(
session
.execute("card = ui.rect(80, 50)", "app.main")
.unwrap()
.document
.is_none()
);
let remounted = session
.execute("page.render(other)", "app.main")
.unwrap()
.document
.unwrap();
assert!(matches!(
remounted.frame.commands[0],
RenderCommand::RoundedRect {
width: 80.0,
height: 50.0,
..
}
));
}
#[test]
fn live_session_retains_objects_and_updates_only_after_mounting() {
let mut session = ReplSession::new(7).unwrap();
let created = session
.execute(
@@ -636,7 +707,7 @@ mod tests {
.execute(r#"title = ui.text("changed")"#, "app.main")
.unwrap()
.document
.is_none()
.is_some()
);
let updated = session
.execute("page.render(title)", "app.main")
+165
View File
@@ -0,0 +1,165 @@
//! Shared native UI references. No VM handles, source replay or variable-name tracking.
use crate::ui_bridge::{MAX_UI_DEPTH, UiKind, UiNode};
use anyhow::{Result, bail};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone)]
pub enum LiveKind {
Leaf(UiKind),
Column {
padding: f32,
gap: f32,
children: Vec<Arc<LiveNode>>,
},
Row {
padding: f32,
gap: f32,
children: Vec<Arc<LiveNode>>,
},
Forward(Arc<LiveNode>),
}
#[derive(Debug)]
pub struct LiveNode {
kind: RwLock<LiveKind>,
}
impl LiveNode {
pub fn new(kind: LiveKind) -> Arc<Self> {
Arc::new(Self {
kind: RwLock::new(kind),
})
}
fn kind(&self) -> Result<LiveKind> {
Ok(self
.kind
.read()
.map_err(|_| anyhow::anyhow!("UI reference is poisoned"))?
.clone())
}
pub fn replace(&self, target: Arc<Self>) -> Result<()> {
// Assigning an alias of this same reference is a no-op, not a forwarding cycle.
let mut current = target.clone();
for _ in 0..=MAX_UI_DEPTH {
if std::ptr::eq(self, current.as_ref()) {
return Ok(());
}
match current.kind()? {
LiveKind::Forward(next) => current = next,
_ => break,
}
}
let mut visits = 0;
inspect(
&target,
Some(self),
&mut vec![self as *const LiveNode],
&mut visits,
)?;
*self
.kind
.write()
.map_err(|_| anyhow::anyhow!("UI reference is poisoned"))? = LiveKind::Forward(target);
Ok(())
}
pub fn snapshot(&self) -> Result<Arc<UiNode>> {
let mut visits = 0;
inspect(self, None, &mut Vec::new(), &mut visits)
}
}
fn inspect(
node: &LiveNode,
forbidden: Option<&LiveNode>,
path: &mut Vec<*const LiveNode>,
visits: &mut usize,
) -> Result<Arc<UiNode>> {
*visits += 1;
if *visits > 10_000 {
bail!("expanded UI graph exceeds 10000 nodes");
}
if path.len() >= MAX_UI_DEPTH {
bail!("UI reference graph exceeds 64 levels");
}
let identity = node as *const LiveNode;
if forbidden.is_some_and(|old| std::ptr::eq(node, old)) || path.contains(&identity) {
bail!("UI assignment would create a reference cycle");
}
path.push(identity);
let kind = match node.kind()? {
LiveKind::Leaf(kind) => kind,
LiveKind::Forward(target) => {
let result = inspect(&target, forbidden, path, visits);
path.pop();
return result;
}
LiveKind::Column {
padding,
gap,
children,
} => UiKind::Column {
padding,
gap,
children: children
.iter()
.map(|child| inspect(child, forbidden, path, visits))
.collect::<Result<_>>()?,
},
LiveKind::Row {
padding,
gap,
children,
} => UiKind::Row {
padding,
gap,
children: children
.iter()
.map(|child| inspect(child, forbidden, path, visits))
.collect::<Result<_>>()?,
},
};
path.pop();
Ok(Arc::new(UiNode { kind }))
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf(value: &str) -> Arc<LiveNode> {
LiveNode::new(LiveKind::Leaf(UiKind::Text {
value: value.into(),
size: 1,
color: "#243047".into(),
}))
}
#[test]
fn replacement_releases_detached_branch_and_preserves_aliases() {
let child = leaf("old");
let weak = Arc::downgrade(&child);
let root = LiveNode::new(LiveKind::Column {
padding: 0.0,
gap: 0.0,
children: vec![child],
});
let alias = root.clone();
root.replace(leaf("new")).unwrap();
assert!(weak.upgrade().is_none());
assert!(
matches!(&alias.snapshot().unwrap().kind, UiKind::Text { value, .. } if value == "new")
);
root.replace(alias).unwrap();
assert!(root.snapshot().is_ok());
}
#[test]
fn self_containing_replacement_is_rejected_before_mutation() {
let root = leaf("kept");
let cycle = LiveNode::new(LiveKind::Row {
padding: 0.0,
gap: 0.0,
children: vec![root.clone()],
});
assert!(root.replace(cycle).is_err());
assert!(
matches!(&root.snapshot().unwrap().kind, UiKind::Text { value, .. } if value == "kept")
);
}
}
+1
View File
@@ -9,6 +9,7 @@ mod endor_host;
mod endor_script;
mod geometry;
mod layout;
mod live_ui;
mod scene;
mod software_renderer;
mod sources;
+239 -67
View File
@@ -1,27 +1,29 @@
//! Tessera-owned UI payloads using Aster's existing embedding interface.
use std::{
any::Any,
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
collections::{HashMap, HashSet},
sync::{Arc, Mutex},
};
use anyhow::{Result, bail};
use endor_core::{EndorReal, ObjectPayload, ObjectRef, Value, Vm, VmError};
use crate::document::parse_color;
use crate::{
document::parse_color,
live_ui::{LiveKind, LiveNode},
};
pub const MAX_UI_NODES: usize = 4096;
pub const MAX_UI_DEPTH: usize = 64;
pub const MAX_PAGES: usize = 64;
pub const MAX_DIMENSION: f64 = 1_000_000.0;
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
pub struct UiNode {
pub kind: UiKind,
depth: usize,
}
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq)]
pub enum UiKind {
Text {
value: String,
@@ -50,7 +52,7 @@ pub enum UiKind {
#[derive(Debug)]
struct NodePayload(UiSlot);
type UiSlot = Arc<OnceLock<Arc<UiNode>>>;
type UiSlot = Arc<Mutex<Option<Arc<LiveNode>>>>;
impl ObjectPayload for NodePayload {
fn as_any(&self) -> &dyn Any {
@@ -59,63 +61,84 @@ impl ObjectPayload for NodePayload {
fn kind_name(&self) -> &'static str {
"tessera-ui-node"
}
fn assign_from(&self, source: &dyn ObjectPayload) -> Result<bool, VmError> {
let Some(source) = source.as_any().downcast_ref::<Self>() else {
return Ok(false);
};
let old = self
.0
.lock()
.map_err(|_| VmError::InvalidArgument("UI slot is poisoned"))?
.clone()
.ok_or(VmError::InvalidArgument("UI reference is uninitialized"))?;
let new = source
.0
.lock()
.map_err(|_| VmError::InvalidArgument("UI slot is poisoned"))?
.clone()
.ok_or(VmError::InvalidArgument("UI source is uninitialized"))?;
old.replace(new).map_err(|_| {
VmError::InvalidArgument("UI assignment creates a cycle or exceeds graph limits")
})?;
Ok(true)
}
}
#[derive(Debug)]
struct BuildSession {
// Borrowed handles only. Candidate owns every pool root until extraction
// and execution have finished. Native child edges contain Arc<UiNode>,
// and execution have finished. Native child edges contain Arc<LiveNode>,
// never VM handles, so payload-owned GC edges remain empty and immutable.
pool: Vec<(ObjectRef, UiSlot)>,
indexes: HashMap<ObjectRef, usize>,
used: usize,
free: Vec<usize>,
pages: Vec<ObjectRef>,
pages_used: usize,
rendered: Option<(usize, Arc<UiNode>)>,
initialized_pages: Vec<bool>,
page_functions: Vec<(ObjectRef, ObjectRef)>,
native_roots: HashSet<usize>,
mounts: Vec<Option<Arc<LiveNode>>>,
active: Option<usize>,
pending: Option<(usize, Option<Arc<LiveNode>>)>,
last_snapshot: Option<Arc<UiNode>>,
}
impl BuildSession {
fn page(&mut self) -> Result<Value, VmError> {
let page = self
.pages
.get(self.pages_used)
let index = self
.initialized_pages
.iter()
.position(|used| !*used)
.ok_or(VmError::InvalidArgument(
"UI build exceeds the 64 page budget",
"UI session exceeds 64 retained pages",
))?;
self.pages_used += 1;
Ok(Value::Object(*page))
self.initialized_pages[index] = true;
Ok(Value::Object(self.pages[index]))
}
fn child(&self, value: &Value) -> Result<Arc<UiNode>, VmError> {
fn child(&self, value: &Value) -> Result<Arc<LiveNode>, VmError> {
let Value::Object(reference) = value else {
return Err(VmError::InvalidArgument("UI children must be UI nodes"));
};
self.indexes
.get(reference)
.and_then(|index| self.pool[*index].1.get())
.cloned()
.ok_or(VmError::InvalidArgument(
"UI children must be initialized nodes from this build",
))
let index = self.indexes.get(reference).ok_or(VmError::InvalidArgument(
"UI child belongs to another session",
))?;
self.pool[*index]
.1
.lock()
.map_err(|_| VmError::InvalidArgument("UI slot is poisoned"))?
.clone()
.ok_or(VmError::InvalidArgument("UI child is uninitialized"))
}
fn emit(&mut self, kind: UiKind) -> Result<Value, VmError> {
let depth = match &kind {
UiKind::Column { children, .. } | UiKind::Row { children, .. } => {
children.iter().map(|node| node.depth).max().unwrap_or(0) + 1
}
_ => 1,
};
if depth > MAX_UI_DEPTH {
return Err(VmError::InvalidArgument("UI tree exceeds 64 levels"));
}
let Some((reference, slot)) = self.pool.get(self.used) else {
return Err(VmError::InvalidArgument(
"UI build exceeds the 4096 node budget",
));
};
slot.set(Arc::new(UiNode { kind, depth }))
.map_err(|_| VmError::InvalidArgument("UI node initialized twice"))?;
self.used += 1;
fn emit(&mut self, kind: LiveKind) -> Result<Value, VmError> {
let node = LiveNode::new(kind);
node.snapshot()
.map_err(|_| VmError::InvalidArgument("UI graph exceeds depth or node limits"))?;
let index = self.free.pop().ok_or(VmError::InvalidArgument(
"UI session exceeds 4096 retained nodes",
))?;
let (reference, slot) = &self.pool[index];
*slot
.lock()
.map_err(|_| VmError::InvalidArgument("UI slot is poisoned"))? = Some(node);
Ok(Value::Object(*reference))
}
}
@@ -201,18 +224,18 @@ impl ObjectPayload for UiConstructor {
} else {
1
};
UiKind::Text {
LiveKind::Leaf(UiKind::Text {
value: value.clone(),
size,
color: color(arguments.get(2), "#242422")?,
}
})
}
Constructor::Rect => UiKind::Rect {
Constructor::Rect => LiveKind::Leaf(UiKind::Rect {
width: dimension(&arguments[0])?,
height: dimension(&arguments[1])?,
radius: arguments.get(2).map(dimension).transpose()?.unwrap_or(0.0),
color: color(arguments.get(3), "#DCE8FF")?,
},
}),
Constructor::Column | Constructor::Row => {
let padding = dimension(&arguments[0])?;
let gap = dimension(&arguments[1])?;
@@ -221,20 +244,20 @@ impl ObjectPayload for UiConstructor {
.map(|value| session.child(value))
.collect::<Result<Vec<_>, _>>()?;
if matches!(constructor, Constructor::Column) {
UiKind::Column {
LiveKind::Column {
padding,
gap,
children,
}
} else {
UiKind::Row {
LiveKind::Row {
padding,
gap,
children,
}
}
}
Constructor::Spacer => UiKind::Spacer(dimension(&arguments[0])?),
Constructor::Spacer => LiveKind::Leaf(UiKind::Spacer(dimension(&arguments[0])?)),
Constructor::Page => return session.page(),
};
session.emit(kind)
@@ -282,11 +305,45 @@ impl ObjectPayload for PageRender {
.session
.lock()
.map_err(|_| VmError::InvalidArgument("UI build session is poisoned"))?;
if self.page >= session.pages_used {
if !session.initialized_pages[self.page] {
return Err(VmError::InvalidArgument("page is not initialized"));
}
let root = session.child(&arguments[0])?;
session.rendered = Some((self.page, root));
session.pending = Some((self.page, Some(root)));
Ok(Value::Nil)
}
}
#[derive(Debug)]
struct PageUnmount {
page: usize,
session: Arc<Mutex<BuildSession>>,
}
impl ObjectPayload for PageUnmount {
fn as_any(&self) -> &dyn Any {
self
}
fn kind_name(&self) -> &'static str {
"tessera-page-unmount"
}
fn call(&self, arguments: &[Value]) -> Result<Value, VmError> {
if !arguments.is_empty() {
return Err(VmError::InvalidArgument("page.unmount takes no arguments"));
}
let mut session = self
.session
.lock()
.map_err(|_| VmError::InvalidArgument("UI session is poisoned"))?;
if session.active == Some(self.page)
|| session
.pending
.as_ref()
.is_some_and(|(page, _)| *page == self.page)
{
session.pending = Some((self.page, None));
} else {
session.mounts[self.page] = None;
}
Ok(Value::Nil)
}
}
@@ -298,20 +355,117 @@ pub struct UiBridge {
}
impl UiBridge {
/// Drain this input's render request; retained UI objects remain in the session pool.
pub fn take_rendered(&self) -> Result<Option<Arc<UiNode>>> {
/// Native mount/child edges retain explicit Aster roots. Drop those roots
/// when the native edge disappears, then recycle only pool-only VM slots.
pub fn reclaim(&self, vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<()> {
let mut session = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI build session is poisoned"))?;
Ok(session.rendered.take().map(|(_, root)| root))
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?;
loop {
let mut freed = false;
for index in 0..session.pool.len() {
let handle = session.pool[index].0;
let referenced = session.pool[index]
.1
.lock()
.map_err(|_| anyhow::anyhow!("UI slot is poisoned"))?
.as_ref()
.is_some_and(|node| Arc::strong_count(node) > 1);
if referenced && !session.native_roots.contains(&index) {
vm.retain(handle)?;
roots.push(handle);
session.native_roots.insert(index);
} else if !referenced && session.native_roots.remove(&index) {
let at = roots
.iter()
.rposition(|root| *root == handle)
.expect("native edge owns a root");
roots.remove(at);
vm.release(handle)?;
}
if !referenced && vm.ref_count(handle)? == 1 {
let removed = session.pool[index]
.1
.lock()
.map_err(|_| anyhow::anyhow!("UI slot is poisoned"))?
.take()
.is_some();
if removed {
session.free.push(index);
freed = true;
}
}
}
if !freed {
break;
}
}
for index in 0..session.pages.len() {
let (render, unmount) = session.page_functions[index];
if session.initialized_pages[index]
&& session.active != Some(index)
&& session.mounts[index].is_none()
&& session
.pending
.as_ref()
.is_none_or(|(page, _)| *page != index)
&& vm.ref_count(session.pages[index])? == 1
&& vm.ref_count(render)? == 2
&& vm.ref_count(unmount)? == 2
{
session.initialized_pages[index] = false;
}
}
Ok(())
}
pub fn discard_pending(&self) -> Result<()> {
self.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?
.pending = None;
Ok(())
}
/// Commit a mount only when layout/publication can succeed. Return None for
/// unchanged pages, Some(None) for an unmounted page, or an updated snapshot.
pub fn prepare_frame(&self) -> Result<Option<Option<Arc<UiNode>>>> {
let session = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?;
let root = match &session.pending {
Some((_, root)) => root.as_ref(),
None => session
.active
.and_then(|index| session.mounts[index].as_ref()),
};
let snapshot = root.map(|root| root.snapshot()).transpose()?;
let changed = session.pending.is_some() || snapshot != session.last_snapshot;
Ok(changed.then_some(snapshot))
}
pub fn commit_frame(&self, snapshot: Option<Arc<UiNode>>) -> Result<()> {
let mut session = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?;
if let Some((page, root)) = session.pending.take() {
session.active = root.as_ref().map(|_| page);
session.mounts[page] = root;
}
session.last_snapshot = snapshot;
Ok(())
}
pub fn rendered(&self) -> Result<Option<Arc<UiNode>>> {
let session = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI build session is poisoned"))?;
Ok(session.rendered.as_ref().map(|(_, root)| root.clone()))
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?;
session
.pending
.as_ref()
.and_then(|(_, root)| root.as_ref())
.map(|root| root.snapshot())
.transpose()
}
}
@@ -352,7 +506,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let mut pool = Vec::with_capacity(MAX_UI_NODES);
let mut indexes = HashMap::with_capacity(MAX_UI_NODES);
for index in 0..MAX_UI_NODES {
let slot = Arc::new(OnceLock::new());
let slot = Arc::new(Mutex::new(None));
let reference = vm.allocate(NodePayload(slot.clone()))?;
roots.push(reference);
indexes.insert(reference, index);
@@ -361,10 +515,15 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let session = Arc::new(Mutex::new(BuildSession {
pool,
indexes,
used: 0,
free: (0..MAX_UI_NODES).rev().collect(),
pages: Vec::with_capacity(MAX_PAGES),
pages_used: 0,
rendered: None,
initialized_pages: vec![false; MAX_PAGES],
page_functions: Vec::with_capacity(MAX_PAGES),
native_roots: HashSet::new(),
mounts: vec![None; MAX_PAGES],
active: None,
pending: None,
last_snapshot: None,
}));
for page in 0..MAX_PAGES {
let reference = vm.allocate(PagePayload)?;
@@ -375,6 +534,18 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
})?;
roots.push(render);
vm.set_attr(reference, "render", Value::Object(render))?;
let unmount = vm.allocate(PageUnmount {
page,
session: session.clone(),
})?;
roots.push(unmount);
vm.set_attr(reference, "unmount", Value::Object(unmount))?;
session
.lock()
.map_err(|_| anyhow::anyhow!("UI session is poisoned"))?
.page_functions
.push((render, unmount));
session
.lock()
.map_err(|_| anyhow::anyhow!("UI build session is poisoned"))?
@@ -405,10 +576,11 @@ pub fn snapshot(vm: &Vm, result: &Value) -> Result<Arc<UiNode>> {
let Value::Object(reference) = result else {
bail!("main must return a UI node");
};
Ok(vm
.payload::<NodePayload>(*reference, "Tessera UI node")?
vm.payload::<NodePayload>(*reference, "Tessera UI node")?
.0
.get()
.lock()
.map_err(|_| anyhow::anyhow!("UI slot is poisoned"))?
.as_ref()
.ok_or_else(|| anyhow::anyhow!("main returned an uninitialized UI node"))?
.clone())
.snapshot()
}