From 757fa22d9bebee555af3104f91103bb045c35875 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 20 Oct 2026 20:58:41 +0800 Subject: [PATCH] feat: retain live page mounts and update shared UI references across aliases --- README.md | 38 ++++-- src/README.md | 16 ++- src/api.rs | 8 +- src/endor_host.rs | 87 +++++++++++-- src/live_ui.rs | 165 +++++++++++++++++++++++++ src/main.rs | 1 + src/ui_bridge.rs | 306 ++++++++++++++++++++++++++++++++++++---------- vendor/aster | 2 +- 8 files changed, 528 insertions(+), 95 deletions(-) create mode 100644 src/live_ui.rs diff --git a/README.md b/README.md index 23defc0..2289bbc 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,14 @@ native tree during redraw and display scale changes without rerunning Endor. The existing callback interface has no allocator access. Tessera therefore preallocates 4096 real UI node objects plus a separate 64-page pool with bound -render callbacks per candidate. Each constructed node is initialized once. +render callbacks per candidate. Each occupied slot owns one live UI reference. Constructors return those borrowed handles; Aster retains call results under its existing rules. Native child data owns its own lifetime and contains no managed -VM edges. Each complete PUT gets a new pool. Console retains its bounded pool until reset; -it still publishes static native snapshots when render is called. +VM edges. Each complete PUT gets a new pool. Console keeps live shared UI references +and page mounts. Native mount/child edges register explicit Aster retain/release +roots; unused VM pool slots are recycled only after no managed or native owner +needs them. A recycled slot gets a fresh native cell. Detached branches are freed +when their last owner disappears; reset releases the session's roots. Limits are 1 MiB source, 10000 visited AST nodes with a 64-level visitor depth, 4096 constructed UI nodes, 64 pages, 64 UI levels, 10000 expanded layout visits @@ -164,7 +167,7 @@ rejected queue entries do not invalidate accepted work. Failed builds do not increment the committed revision. A worker-caught panic discards the entire candidate and returns 500, retaining the previous document. -This stage supports static snapshot UI and one live Console VM on the existing worker. +This stage supports live mounted UI in the Console VM and immutable window frames on the existing worker. Event callbacks, window-size language inputs and process isolation are future work. Worker threads, source budgets and VM instruction limits are not a complete untrusted-code sandbox. @@ -187,7 +190,7 @@ appears directly below. Shift-Enter inserts a newline. Each command and its resu are followed by a thin neutral horizontal separator. Input focus uses a text caret, without a blue field border or hover fill. The prompt, history and caret share the same scroll offset; typing and cursor navigation reveal the current insertion point. -Create objects with bare assignment or `let`, then render them when ready: +Create objects with bare assignment or `let`, then mount them when ready: ```endor title = ui.text("你好,Tessera", 2, "#243047") @@ -203,6 +206,22 @@ page = ui.page() page.render(ui.column(24, 16, title, note, card)) ``` +Ordinary values keep normal name assignment. UI references opt into Aster's +`ObjectPayload::assign_from` protocol: assigning another UI object updates the +existing shared reference and preserves its identity. Aliases and mounted parents +therefore observe the same replacement, including across function-local slots. +For example, after `other = card` and `page.render(other)`, assigning +`card = ui.rect(640, 200, 32)` updates the page without another render. This is +shared-reference semantics, not variable-name tracking. Assigning a non-UI value +uses normal value assignment; it does not unmount the old UI reference. + +`page.render(root)` mounts a live root and selects its page. Changes to mounted +references publish a new frame after a successful input; unrelated objects do not. +`page.unmount()` releases that page's mount and clears the frame if it was active. +Replacing a container updates its whole mounted position. Repeated aliases are +allowed; replacements that would create a cycle or exceed graph limits are rejected +before mutation. Scalar constructor arguments are evaluated once, as ordinary values. + Bare assignment creates/updates a name; `let` remains a declaration. Aster owns this syntax, AST and bytecode; Tessera never inserts `let` or replays history. The actual last value is also bound to `_`. Text editing supports cursor positioning, arrow @@ -211,15 +230,16 @@ Up/Down recall single-line history; Command/Control-Up/Down recall multiline his Console sends `POST /v1/namespaces/app.main/repl` as UTF-8 text/plain. Responses include value, session/input IDs, bindings, `persistent: true`, rendered flag and -revision. Object creation alone never publishes a page or advances revision. -`page.render` publishes the last render only after the input succeeds. Syntax and +revision. Before the first mount, object creation never publishes a page or advances revision. +After mounting, changed shared UI references publish automatically. The last explicit +render in an input selects the active page, only after that input succeeds. Syntax and compilation errors run no statements; runtime errors can retain earlier successful statements from that input, while preserving the previously displayed page. Reset session (`DELETE .../repl`) drops the VM and all bindings/objects, preserving -the displayed native snapshot. Clear only hides the transcript. Limits per session +the displayed immutable frame. Clear only hides the transcript. Limits per session are 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes, 4096 UI nodes -and 64 pages; reset reclaims the whole pool. Code and objects are never stored on disk. +and 64 retained pages. Unused slots are reclaimed between inputs; reset releases the whole session. Code and objects are never stored on disk. Existing `PUT .../code` remains a complete program replacement with a fresh VM; function-only source still invokes main. Accepted PUT/code DELETE invalidate the diff --git a/src/README.md b/src/README.md index d2daa0c..1b0ba43 100644 --- a/src/README.md +++ b/src/README.md @@ -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. diff --git a/src/api.rs b/src/api.rs index 9bd691a..20cc1ee 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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. diff --git a/src/endor_host.rs b/src/endor_host.rs index d64abb4..3271e4d 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -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") diff --git a/src/live_ui.rs b/src/live_ui.rs new file mode 100644 index 0000000..1455605 --- /dev/null +++ b/src/live_ui.rs @@ -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>, + }, + Row { + padding: f32, + gap: f32, + children: Vec>, + }, + Forward(Arc), +} + +#[derive(Debug)] +pub struct LiveNode { + kind: RwLock, +} +impl LiveNode { + pub fn new(kind: LiveKind) -> Arc { + Arc::new(Self { + kind: RwLock::new(kind), + }) + } + fn kind(&self) -> Result { + Ok(self + .kind + .read() + .map_err(|_| anyhow::anyhow!("UI reference is poisoned"))? + .clone()) + } + pub fn replace(&self, target: Arc) -> 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> { + 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> { + *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::>()?, + }, + LiveKind::Row { + padding, + gap, + children, + } => UiKind::Row { + padding, + gap, + children: children + .iter() + .map(|child| inspect(child, forbidden, path, visits)) + .collect::>()?, + }, + }; + path.pop(); + Ok(Arc::new(UiNode { kind })) +} + +#[cfg(test)] +mod tests { + use super::*; + fn leaf(value: &str) -> Arc { + 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") + ); + } +} diff --git a/src/main.rs b/src/main.rs index bd84ee4..0ba7e70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod endor_host; mod endor_script; mod geometry; mod layout; +mod live_ui; mod scene; mod software_renderer; mod sources; diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 62300cb..1c5e014 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -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>>; +type UiSlot = Arc>>>; 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 { + let Some(source) = source.as_any().downcast_ref::() 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, + // and execution have finished. Native child edges contain Arc, // never VM handles, so payload-owned GC edges remain empty and immutable. pool: Vec<(ObjectRef, UiSlot)>, indexes: HashMap, - used: usize, + free: Vec, pages: Vec, - pages_used: usize, - rendered: Option<(usize, Arc)>, + initialized_pages: Vec, + page_functions: Vec<(ObjectRef, ObjectRef)>, + native_roots: HashSet, + mounts: Vec>>, + active: Option, + pending: Option<(usize, Option>)>, + last_snapshot: Option>, } impl BuildSession { fn page(&mut self) -> Result { - 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, VmError> { + fn child(&self, value: &Value) -> Result, 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 { - 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 { + 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::, _>>()?; 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>, +} +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 { + 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>> { + /// 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) -> 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>>> { + 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>) -> 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>> { 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) -> Result { 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) -> Result { 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) -> Result { })?; 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> { let Value::Object(reference) = result else { bail!("main must return a UI node"); }; - Ok(vm - .payload::(*reference, "Tessera UI node")? + vm.payload::(*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() } diff --git a/vendor/aster b/vendor/aster index df1b77f..7c84278 160000 --- a/vendor/aster +++ b/vendor/aster @@ -1 +1 @@ -Subproject commit df1b77fc1ab5fd4dc37f4a4fce8744fc0e91e7d5 +Subproject commit 7c842789c0f64629b6c0b4b4424fd61a41529a58