feat: retain live page mounts and update shared UI references across aliases
Verify Tessera GUI / verify (push) Failing after 7m13s
Verify Tessera GUI / verify (push) Failing after 7m13s
This commit is contained in:
@@ -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
|
||||
|
||||
+10
-6
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/aster updated: df1b77fc1a...7c842789c0
Reference in New Issue
Block a user