Files
endorphin-tessera-gui/src/api.rs
T
codex-bot 5256ac1318
Verify Tessera GUI / verify (push) Failing after 7m46s
docs: demonstrate Endor await and task result propagation
2026-10-23 20:15:10 +08:00

2064 lines
109 KiB
Rust

use std::{
io::Read,
ops::{Deref, DerefMut},
panic::{AssertUnwindSafe, catch_unwind},
sync::Mutex,
sync::{
Arc, RwLock,
mpsc::{SyncSender, TrySendError, sync_channel},
},
thread,
};
use anyhow::{Context as _, Result};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use winit::event_loop::EventLoopProxy;
use crate::{
activity::{Activity, ActivityLog},
app::AppEvent,
document::NamespaceState,
endor_host,
};
pub const API_ADDRESS: &str = "127.0.0.1:4737";
const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
type Notify = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RequestKind {
Event,
Code,
Expression,
Repl,
ResetRepl,
}
struct BuildRequest {
request: ObservedRequest,
kind: RequestKind,
source: String,
namespace: String,
submission: u64,
}
pub fn start(
namespace: Arc<RwLock<NamespaceState>>,
proxy: EventLoopProxy<AppEvent>,
) -> Result<Activity> {
let server = Server::http(API_ADDRESS)
.map_err(|error| anyhow::anyhow!(error))
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
let notify: Notify = Arc::new(move || {
let _ = proxy.send_event(AppEvent::DocumentUpdated);
});
let activity = Arc::new(Mutex::new(ActivityLog::default()));
start_server_observed(
Arc::new(server),
namespace,
notify.clone(),
activity.clone(),
notify,
)?;
Ok(activity)
}
#[cfg(test)]
fn start_server(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
notify: Notify,
) -> Result<thread::JoinHandle<()>> {
start_server_observed(
server,
namespace,
notify,
Arc::new(Mutex::new(ActivityLog::default())),
Arc::new(|| {}),
)
}
fn start_server_observed(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
notify: Notify,
activity: Activity,
observer_notify: Notify,
) -> Result<thread::JoinHandle<()>> {
let (sender, receiver) = sync_channel::<BuildRequest>(8);
let worker_namespace = namespace.clone();
let worker_notify = notify.clone();
thread::Builder::new()
.name("tessera-endor-worker".into())
.spawn(move || {
let mut worker = Worker::default();
let mut pending: Option<(BuildRequest, PendingRequest)> = None;
loop {
if let Some((job, input)) = pending.take() {
match worker.resume_request(&job, input, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => {
pending = Some((job, input));
thread::sleep(std::time::Duration::from_millis(1));
}
RequestProgress::Complete(response) => {
let _ = job.request.respond(response);
}
}
continue;
}
worker.refresh_viewport(&worker_namespace, &worker_notify);
let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| {
runtime.has_pending_refresh() || runtime.has_cooperative_tasks()
}) {
std::time::Duration::from_millis(1)
} else {
std::time::Duration::from_millis(100)
};
match receiver.recv_timeout(wait) {
Ok(job) => {
match worker.begin_request(&job, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => pending = Some((job, input)),
RequestProgress::Complete(response) => {
let _ = job.request.respond(response);
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if worker.repl.as_ref().is_some_and(|(epoch, runtime)| {
worker_namespace.read().is_ok_and(|current| {
current.submission != *epoch
&& current.runtime_session != runtime.id
})
}) {
worker.repl = None;
}
}
}
}
})
.context("start Aster execution worker")?;
thread::Builder::new()
.name("tessera-agent-api".to_owned())
.spawn(move || {
for request in server.incoming_requests() {
handle(
request,
&namespace,
&notify,
&sender,
&activity,
&observer_notify,
);
}
})
.context("start Tessera agent API thread")
}
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
struct ObservedRequest {
inner: Request,
activity: Activity,
id: u64,
notify: Notify,
}
impl ObservedRequest {
fn new(inner: Request, activity: Activity, notify: Notify) -> Self {
let headers = inner
.headers()
.iter()
.filter(|h| h.field.equiv("Content-Type") || h.field.equiv("Content-Length"))
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let origin = if inner
.headers()
.iter()
.any(|h| h.field.equiv("X-Tessera-Origin") && h.value.as_str() == "console")
{
"Console"
} else {
"HTTP"
}
.to_owned();
let id = activity.lock().unwrap().begin(
inner.method().to_string(),
inner.url().to_owned(),
origin,
headers,
);
notify();
Self {
inner,
activity,
id,
notify,
}
}
fn record_body(&self, body: &str) {
self.activity.lock().unwrap().body(self.id, body);
(self.notify)();
}
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
let status = response.status_code();
let headers = response.headers().to_vec();
let length = response.data_length();
let reader = response.into_reader();
self.activity.lock().unwrap().finish(
self.id,
status.0,
&String::from_utf8_lossy(reader.get_ref()),
headers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
);
(self.notify)();
self.inner
.respond(Response::new(status, headers, reader, length, None))
}
}
impl Deref for ObservedRequest {
type Target = Request;
fn deref(&self) -> &Request {
&self.inner
}
}
impl DerefMut for ObservedRequest {
fn deref_mut(&mut self) -> &mut Request {
&mut self.inner
}
}
fn handle(
request: Request,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
sender: &SyncSender<BuildRequest>,
activity: &Activity,
observer_notify: &Notify,
) {
let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone());
let method = request.method().clone();
let path = request
.url()
.split('?')
.next()
.unwrap_or(request.url())
.to_owned();
let evaluation = namespace_from_route(&path, "/evaluate");
let repl = namespace_from_route(&path, "/repl");
let event = namespace_from_route(&path, "/events");
let route = if method == Method::Put {
namespace_from_path(&path).map(|name| (name, RequestKind::Code))
} else if method == Method::Post {
event
.map(|name| (name, RequestKind::Event))
.or_else(|| repl.map(|name| (name, RequestKind::Repl)))
.or(evaluation.map(|name| (name, RequestKind::Expression)))
} else if method == Method::Delete {
repl.map(|name| (name, RequestKind::ResetRepl))
} else {
None
};
if let Some((addressed, kind)) = route {
submit(request, addressed, namespace, sender, kind);
return;
}
let response = if method == Method::Get && path == "/agent" {
match namespace.read() {
Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)),
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
}
} else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) {
code_request(method, &addressed_namespace, namespace, notify)
} else if method == Method::Options {
text(StatusCode(204), "", "text/plain")
} else {
error_response(StatusCode(404), "route not found; start with GET /agent")
};
let _ = request.respond(response);
}
fn code_request(
method: Method,
addressed_namespace: &str,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
let Ok(current) = namespace.read() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if addressed_namespace != current.name {
return error_response(
StatusCode(404),
format!(
"namespace `{addressed_namespace}` is not active; current namespace is `{}`",
current.name
),
);
}
if method == Method::Get {
let mut response = text(
StatusCode(200),
&current.document.source,
"text/plain; charset=utf-8",
);
add_header(
&mut response,
"X-Tessera-Revision",
&current.revision.to_string(),
);
add_header(
&mut response,
"X-Tessera-Session",
&current.document.frame.session.to_string(),
);
add_header(
&mut response,
"X-Tessera-Window",
&current.document.frame.window.to_string(),
);
add_header(
&mut response,
"X-Tessera-Layout-Version",
&current.document.frame.layout_version.to_string(),
);
if let Some(viewport) = current.viewport {
add_header(
&mut response,
"X-Tessera-Viewport-Width",
&viewport.width.to_string(),
);
add_header(
&mut response,
"X-Tessera-Viewport-Height",
&viewport.height.to_string(),
);
}
add_header(
&mut response,
"X-Tessera-Source-Kind",
if current
.document
.root
.as_ref()
.is_some_and(|root| root.kind == "repl-session")
{
"repl-transcript"
} else {
"source-snapshot"
},
);
return response;
}
drop(current);
match method {
Method::Delete => match namespace.write() {
Ok(mut current) => {
let revision = match current.clear() {
Ok(revision) => revision,
Err(error) => return error_response(StatusCode(500), error.to_string()),
};
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({
"ok": true,
"namespace": addressed_namespace,
"revision": revision
}),
)
}
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
},
Method::Options => text(StatusCode(204), "", "text/plain"),
_ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"),
}
}
fn submit(
mut request: ObservedRequest,
addressed: &str,
namespace: &Arc<RwLock<NamespaceState>>,
sender: &SyncSender<BuildRequest>,
kind: RequestKind,
) {
let source = match read_code(&mut request) {
Ok(source) => source,
Err(error) => {
let _ = request.respond(error_response(StatusCode(400), error.to_string()));
return;
}
};
request.record_body(&source);
let Ok(mut current) = namespace.write() else {
let _ = request.respond(error_response(
StatusCode(500),
"namespace lock is poisoned",
));
return;
};
if current.name != addressed {
let _ = request.respond(error_response(StatusCode(404), "namespace is not active"));
return;
}
let submission = match if kind == RequestKind::Code {
current.next_submission()
} else {
Ok(current.submission)
} {
Ok(id) => id,
Err(error) => {
let _ = request.respond(error_response(StatusCode(500), error.to_string()));
return;
}
};
let job = BuildRequest {
request,
kind,
source,
namespace: addressed.into(),
submission,
};
match sender.try_send(job) {
// A rejected queue entry never invalidates accepted work. The worker
// uses this same lock when committing, so publication is atomic.
Ok(()) => {
if kind == RequestKind::Code {
current.submission = submission;
}
}
Err(TrySendError::Full(job)) => {
let _ = job
.request
.respond(error_response(StatusCode(503), "build queue is full"));
}
Err(TrySendError::Disconnected(job)) => {
let _ = job.request.respond(error_response(
StatusCode(503),
"execution worker is unavailable",
));
}
}
}
type HttpResponse = Response<std::io::Cursor<Vec<u8>>>;
type InputOutcome =
std::thread::Result<Result<(endor_host::ReplResult, Vec<crate::window::Command>)>>;
type CandidateOutcome = std::thread::Result<
Result<(
endor_host::ReplSession,
crate::document::RenderDocument,
Vec<crate::window::Command>,
)>,
>;
struct PendingInput {
epoch: u64,
id: u64,
event: bool,
result: endor_host::ReplResult,
}
enum PendingRequest {
Input(Box<PendingInput>),
Candidate(Box<endor_host::ReplSession>),
}
enum RequestProgress {
Pending(PendingRequest),
Complete(HttpResponse),
}
fn current_viewports(
current: &NamespaceState,
) -> std::collections::BTreeMap<u64, crate::layout::Size> {
let mut viewports: std::collections::BTreeMap<_, _> = current
.window_viewports
.iter()
.filter(|(id, _)| {
**id == crate::window::MAIN_WINDOW
|| (current.runtime_session != 0
&& current
.document
.windows
.get(id)
.is_some_and(|view| view.frame.session == current.runtime_session))
})
.map(|(id, size)| (*id, *size))
.collect();
if let Some(viewport) = current.viewport {
viewports.insert(crate::window::MAIN_WINDOW, viewport);
}
viewports
}
#[derive(Default)]
struct Worker {
repl: Option<(u64, endor_host::ReplSession)>,
next_session: u64,
}
impl Worker {
fn close_inactive_window(
&mut self,
input: &crate::events::Input,
epoch: u64,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if input.window == crate::window::MAIN_WINDOW {
return error_response(
StatusCode(409),
"close the primary shell with its OS window control",
);
}
let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| {
*owner == epoch && runtime.id == input.session && input.session != 0
});
let mut discarded = false;
if owns_runtime {
let result = catch_unwind(AssertUnwindSafe(|| {
self.repl
.as_mut()
.expect("owned runtime")
.1
.retire_window(input.window)
}));
if !matches!(result, Ok(Ok(()))) {
// Never keep a half-cleaned VM live or borrow another generation.
self.repl = None;
discarded = true;
}
}
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
};
if current.submission != epoch {
return superseded(epoch);
}
let Some(view) = current.document.windows.get(&input.window) else {
return error_response(StatusCode(409), "window is no longer active");
};
if view.frame.session != input.session {
return error_response(
StatusCode(409),
"window belongs to another runtime generation",
);
}
let mut document = current.document.clone();
document.windows.remove(&input.window);
if discarded {
document.frame.session = 0;
document.frame.focus = None;
for view in document.windows.values_mut() {
view.frame.session = 0;
view.frame.focus = None;
}
current.runtime_session = 0;
current.window_commands.clear();
}
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(epoch),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
current.window_viewports.remove(&input.window);
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":"window-retire","window":input.window,"callbacks":false,"runtime_discarded":discarded}),
)
}
fn refresh_viewport(&mut self, namespace: &Arc<RwLock<NamespaceState>>, notify: &Notify) {
self.refresh_viewport_mode(namespace, notify, false);
}
fn refresh_viewport_mode(
&mut self,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
drain: bool,
) {
let (epoch, viewports) = match namespace.read() {
Ok(current) => {
let viewports = current_viewports(&current);
(current.submission, viewports)
}
Err(_) => return,
};
let Some((owner, runtime)) = &mut self.repl else {
return;
};
if *owner != epoch {
if let Ok(current) = namespace.read() {
self.preserve_live_owner(&current);
}
return;
}
let id = runtime.id;
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
if runtime.is_faulted() {
runtime.poll_tasks()?;
return Ok((None, Vec::new()));
}
let resized = if drain {
runtime.resize_windows(&viewports)?
} else {
runtime.resize_windows_incremental(&viewports, false)?
};
if runtime.has_pending_refresh() {
return Ok((None, Vec::new()));
}
let completed = if drain {
runtime.poll_tasks()?
} else {
runtime.poll_tasks_incremental(&viewports)?
};
if runtime.has_pending_refresh() {
// A complete earlier resize may publish; task effects and their
// native commands wait until the task's refresh has settled.
return Ok((resized, Vec::new()));
}
Ok((completed.or(resized), runtime.take_window_commands()?))
}));
let mut current = match namespace.write() {
Ok(current) => current,
Err(_) => return,
};
if current.submission != epoch || current.runtime_session != id {
return;
}
let (document, commands) = match result {
Ok(Ok((document, commands))) => {
if let Some(document) = document {
(document, Some(commands))
} else {
let projected = !commands.is_empty();
current.publish_window_commands(id, commands);
drop(current);
if projected {
notify();
}
return;
}
}
failure => {
let mut document = current.document.clone();
document.frame.fault = Some(match failure {
Ok(Err(error)) => format!("Runtime background update failed: {error:#}")
.chars()
.take(1024)
.collect(),
_ => {
self.repl = None;
current.runtime_session = 0;
current.window_commands.clear();
"Runtime discarded after an internal background failure".into()
}
});
for view in document.windows.values_mut() {
view.frame.fault = document.frame.fault.clone();
}
(document, None)
}
};
let changed = matches!(current.commit(epoch, document), Ok(Some(_)));
if changed && let Some(commands) = commands {
current.publish_window_commands(id, commands);
}
drop(current);
if changed {
notify();
}
}
fn begin_request(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
if job.kind == RequestKind::Code {
return self.begin_program_request(job, namespace, notify);
}
if job.kind == RequestKind::Expression {
return RequestProgress::Complete(build(job, namespace, notify));
}
// Mutating requests serialize behind a pending refresh. Idle polling
// parks between quanta, but input never interleaves heap mutation with
// an active read-only call or publishes a partly refreshed window set.
self.refresh_viewport_mode(namespace, notify, true);
let epoch = match namespace.read() {
Ok(current) => current.submission,
Err(_) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock is poisoned",
));
}
};
if epoch != job.submission {
return RequestProgress::Complete(superseded(job.submission));
}
if job.kind == RequestKind::ResetRepl {
self.repl = None;
if let Ok(mut current) = namespace.write() {
current.document.frame.session = 0;
current.document.frame.focus = None;
for document in current.document.windows.values_mut() {
document.frame.session = 0;
document.frame.focus = None;
}
current.runtime_session = 0;
current.window_commands.clear();
}
notify();
return RequestProgress::Complete(json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}),
));
}
let event = if job.kind == RequestKind::Event {
let input: crate::events::Input = match serde_json::from_str(&job.source) {
Ok(input) => input,
Err(error) => {
return RequestProgress::Complete(error_response(
StatusCode(400),
error.to_string(),
));
}
};
if !matches!(
input.kind.as_str(),
"click"
| "focus"
| "scroll"
| "tab-activate"
| "tab-close"
| "tab-detach"
| "tab-drop"
| "window-close"
) {
return RequestProgress::Complete(error_response(
StatusCode(400),
"unsupported event kind",
));
}
if let Err(message) = input.validate() {
return RequestProgress::Complete(error_response(StatusCode(400), message));
}
let current = match namespace.read() {
Ok(current) => current,
Err(_) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock poisoned",
));
}
};
let frame = if input.window == crate::window::MAIN_WINDOW {
&current.document.frame
} else {
let Some(document) = current.document.windows.get(&input.window) else {
return RequestProgress::Complete(error_response(
StatusCode(409),
"window is no longer active",
));
};
&document.frame
};
if input.kind == "window-close"
&& frame.window == input.window
&& frame.session == input.session
{
let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| {
*owner == epoch && runtime.id == input.session && input.session != 0
});
let stopped = frame.fault.is_some()
|| !owns
|| self
.repl
.as_ref()
.is_some_and(|(_, runtime)| runtime.is_faulted());
if stopped {
drop(current);
return RequestProgress::Complete(
self.close_inactive_window(&input, epoch, namespace, notify),
);
}
}
if let Some(target) = &input.tab_drop {
let target_frame = if target.window == crate::window::MAIN_WINDOW {
Some(&current.document.frame)
} else {
current
.document
.windows
.get(&target.window)
.map(|document| &document.frame)
};
if target_frame.is_none_or(|frame| {
frame.window != target.window
|| frame.session != input.session
|| frame.layout_version != target.layout_version
|| frame.fault.is_some()
|| target.index > frame.tabs.len()
}) || self
.repl
.as_ref()
.is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input))
{
return RequestProgress::Complete(error_response(
StatusCode(409),
"drop targets an obsolete or inactive frame",
));
}
}
if frame.window != input.window
|| frame.session != input.session
|| (input.kind != "window-close" && frame.layout_version != input.layout_version)
|| frame.fault.is_some()
|| if input.kind == "window-close" {
false
} else if matches!(
input.kind.as_str(),
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
) {
!frame.tabs.iter().any(|tab| tab.id == input.mount)
} else {
!frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& (mount.interactive
|| input.kind == "scroll" && mount.scroll.is_some())
})
}
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
*context != epoch
|| runtime.id != input.session
|| (input.kind != "window-close" && !runtime.accepts_window(input.window))
})
{
return RequestProgress::Complete(error_response(
StatusCode(409),
"event targets an obsolete or inactive frame",
));
}
Some(input)
} else {
None
};
if self
.repl
.as_ref()
.is_none_or(|(current, _)| *current != epoch)
{
self.next_session = match self.next_session.checked_add(1) {
Some(id) => id,
None => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"session identities exhausted",
));
}
};
let viewport = namespace.read().ok().and_then(|current| current.viewport);
match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) {
Ok(session) => {
if let Ok(mut current) = namespace.write()
&& current.submission == epoch
{
current.publish_window_commands(session.id, Vec::new());
}
self.repl = Some((epoch, session));
}
Err(error) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
error.to_string(),
));
}
}
}
let viewports = match namespace.read() {
Ok(current) => current_viewports(&current),
Err(_) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock is poisoned",
));
}
};
let session = &mut self.repl.as_mut().expect("initialized REPL").1;
let id = session.id;
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
let result = if let Some(event) = &event {
session.dispatch_incremental(event, &viewports)?
} else {
session.execute_incremental(&job.source, &job.namespace, &viewports)?
};
let commands = if session.has_pending_refresh() {
Vec::new()
} else {
session.take_window_commands()?
};
Ok((result, commands))
}));
let faulted = session.is_faulted();
if session.has_pending_refresh() {
if let Ok(Ok((result, _))) = result {
return RequestProgress::Pending(PendingRequest::Input(Box::new(PendingInput {
epoch,
id,
event: event.is_some(),
result,
})));
}
// A failed start must never leave a request waiting on a broken VM.
self.repl = None;
}
RequestProgress::Complete(self.finish_input(
job,
namespace,
notify,
epoch,
id,
event.is_some(),
faulted,
result,
))
}
fn resume_request(
&mut self,
job: &BuildRequest,
pending: PendingRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
match pending {
PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify),
PendingRequest::Candidate(runtime) => {
self.resume_candidate(job, runtime, namespace, notify)
}
}
}
fn resume_input(
&mut self,
job: &BuildRequest,
mut input: Box<PendingInput>,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
let viewports = match namespace.read() {
Ok(current)
if current.submission == input.epoch || current.runtime_session == input.id =>
{
current_viewports(&current)
}
Ok(_) => {
self.repl = None;
return RequestProgress::Complete(superseded(job.submission));
}
Err(_) => {
self.repl = None;
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock is poisoned",
));
}
};
let Some((epoch, session)) = &mut self.repl else {
return RequestProgress::Complete(error_response(
StatusCode(409),
"input runtime was discarded",
));
};
if *epoch != input.epoch || session.id != input.id {
return RequestProgress::Complete(superseded(job.submission));
}
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
let document = session.resize_windows_incremental(&viewports, false)?;
if session.has_pending_refresh() {
return Ok(None);
}
Ok(Some((document, session.take_window_commands()?)))
}));
let faulted = session.is_faulted();
let result = match resumed {
Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)),
Ok(Ok(Some((document, commands)))) => {
input.result.document = document;
Ok(Ok((input.result, commands)))
}
Ok(Err(error)) => Ok(Err(error)),
Err(panic) => Err(panic),
};
RequestProgress::Complete(self.finish_input(
job,
namespace,
notify,
input.epoch,
input.id,
input.event,
faulted,
result,
))
}
#[allow(clippy::too_many_arguments)]
fn finish_input(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
epoch: u64,
id: u64,
event: bool,
faulted: bool,
result: InputOutcome,
) -> HttpResponse {
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
let epoch = if current.submission != epoch {
if current.runtime_session != id {
drop(current);
self.repl = None;
return superseded(job.submission);
}
// Accepted PUTs invalidate queued inputs, not a mutation already
// executing on this owner. Settle it before building the candidate
// so a failed replacement can preserve a consistent live heap.
if let Some((owner, runtime)) = &mut self.repl
&& runtime.id == id
{
*owner = current.submission;
}
current.submission
} else {
epoch
};
match result {
Ok(Ok((result, commands))) => {
let projected = !commands.is_empty();
let rendered = result.document.is_some();
if let Some(document) = result.document {
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(job.submission),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
current.publish_window_commands(id, commands);
let revision = current.revision;
drop(current);
if rendered || projected {
notify();
}
json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":if event { "event" } else { "repl" },"persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}),
)
}
Ok(Err(error)) => {
if event || faulted {
let mut document = current.document.clone();
// Preserve the first failure of this VM; subsequent
// rejected Console input must not hide the callback cause.
// A preserved picture from another generation is unrelated.
let fault = (document.frame.session == id)
.then(|| document.frame.fault.clone())
.flatten()
.unwrap_or_else(|| format!("{error:#}").chars().take(1024).collect());
document.frame.fault = Some(fault.clone());
for view in document.windows.values_mut() {
view.frame.fault = Some(fault.clone());
}
let _ = current.commit(epoch, document);
drop(current);
notify();
}
compile_error(error)
}
Err(_) => {
let mut document = current.document.clone();
document.frame.fault =
Some("Runtime discarded after an internal execution failure".into());
for view in document.windows.values_mut() {
view.frame.fault = document.frame.fault.clone();
}
current.runtime_session = 0;
current.window_commands.clear();
let _ = current.commit(epoch, document);
drop(current);
self.repl = None;
notify();
json(
StatusCode(500),
serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}),
)
}
}
}
/// An accepted PUT changes the submission, but does not revoke the old
/// runtime. DELETE clears runtime_session and does revoke it.
fn preserve_live_owner(&mut self, current: &NamespaceState) {
if let Some((epoch, runtime)) = &mut self.repl {
if current.runtime_session == runtime.id {
*epoch = current.submission;
} else {
self.repl = None;
}
}
}
fn begin_program_request(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
let viewport = match namespace.read() {
Ok(current) if current.submission == job.submission => current.viewport,
Ok(current) => {
self.preserve_live_owner(&current);
return RequestProgress::Complete(superseded(job.submission));
}
Err(_) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock poisoned",
));
}
};
self.next_session = match self.next_session.checked_add(1) {
Some(id) => id,
None => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"session identities exhausted",
));
}
};
let id = self.next_session;
let started = catch_unwind(AssertUnwindSafe(|| {
endor_host::ReplSession::load_incremental(
&job.source,
&job.namespace,
job.submission,
id,
viewport,
)
}));
let result = match started {
Ok(Ok((runtime, None))) => {
return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime)));
}
Ok(Ok((runtime, Some(document)))) => match runtime.take_window_commands() {
Ok(commands) => Ok(Ok((runtime, document, commands))),
Err(error) => Ok(Err(error)),
},
Ok(Err(error)) => Ok(Err(error)),
Err(panic) => Err(panic),
};
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
fn resume_candidate(
&mut self,
job: &BuildRequest,
mut runtime: Box<endor_host::ReplSession>,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
let viewports = match namespace.read() {
Ok(current) if current.submission == job.submission => {
// Secondary native resources still belong to the old VM. Only
// the primary shell's current viewport applies to a candidate.
current
.viewport
.map(|size| (crate::window::MAIN_WINDOW, size))
.into_iter()
.collect()
}
Ok(current) => {
self.preserve_live_owner(&current);
return RequestProgress::Complete(superseded(job.submission));
}
Err(_) => {
return RequestProgress::Complete(error_response(
StatusCode(500),
"namespace lock poisoned",
));
}
};
let id = runtime.id;
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
let document = runtime.resize_windows_incremental(&viewports, false)?;
if runtime.has_pending_refresh() {
return Ok(None);
}
let document =
document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?;
Ok(Some((document, runtime.take_window_commands()?)))
}));
let result = match resumed {
Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)),
Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))),
Ok(Err(error)) => Ok(Err(error)),
Err(panic) => Err(panic),
};
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
fn finish_candidate(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
id: u64,
result: CandidateOutcome,
) -> HttpResponse {
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
};
if current.submission != job.submission {
self.preserve_live_owner(&current);
return superseded(job.submission);
}
match result {
Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document)
{
Ok(Some(revision)) => {
current.publish_window_commands(id, commands);
self.repl = Some((job.submission, runtime));
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({"ok":true,"namespace":job.namespace,"submission":job.submission,"revision":revision,"diagnostics":[],"session":id,"persistent":true}),
)
}
Ok(None) => superseded(job.submission),
Err(error) => error_response(StatusCode(500), error.to_string()),
},
failed => {
// The candidate never mutated the displayed program's heap.
// Restore that program's event epoch when a replacement fails.
self.preserve_live_owner(&current);
drop(current);
match failed {
Ok(Err(error)) => compile_error(error),
_ => error_response(
StatusCode(500),
"candidate failed internally; previous runtime preserved",
),
}
}
}
}
}
fn build(
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if job.kind == RequestKind::Expression {
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
Ok(Ok(value)) => json(
StatusCode(200),
serde_json::json!({"ok":true,"value":value,"execution":"isolated-expression","persistent":false}),
),
Ok(Err(error)) => compile_error(error),
Err(_) => error_response(StatusCode(500), "expression execution failed internally"),
};
}
match namespace.read() {
Ok(current) if current.submission != job.submission => return superseded(job.submission),
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
_ => {}
}
// Only an independent native snapshot crosses into the published state.
// A fresh VM is discarded on success, error and unwind alike.
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::compile(&job.source, &job.namespace, job.submission)
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if current.submission != job.submission {
return superseded(job.submission);
}
match result {
Ok(Ok(document)) => match current.commit(job.submission, document) {
Ok(Some(revision)) => {
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({ "ok": true, "namespace": job.namespace,
"submission": job.submission, "revision": revision, "diagnostics": [] }),
)
}
Ok(None) => superseded(job.submission),
Err(error) => error_response(StatusCode(500), error.to_string()),
},
Ok(Err(error)) => compile_error(error),
Err(_) => error_response(
StatusCode(500),
"candidate execution failed internally; previous document preserved",
),
}
}
fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
json(
StatusCode(409),
serde_json::json!({ "ok": false, "submission": submission,
"error": "submission superseded by a newer PUT or DELETE" }),
)
}
fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> {
path.strip_prefix("/v1/namespaces/")?
.strip_suffix(suffix)
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
}
fn namespace_from_path(path: &str) -> Option<&str> {
namespace_from_route(path, "/code")
}
fn read_code(request: &mut Request) -> Result<String> {
if request
.body_length()
.is_some_and(|length| length as u64 > MAX_CODE_SIZE)
{
anyhow::bail!("code exceeds the 1 MiB limit");
}
let mut code = String::new();
request
.as_reader()
.take(MAX_CODE_SIZE + 1)
.read_to_string(&mut code)
.context("read code")?;
if code.len() as u64 > MAX_CODE_SIZE {
anyhow::bail!("code exceeds the 1 MiB limit");
}
Ok(code)
}
fn agent_guide(namespace: &NamespaceState) -> String {
format!(
r##"# Tessera Agent API
Current namespace: `{name}`
Current revision: `{revision}`
Submit an ordinary Endor script. Tessera embeds Aster's frontend, compiler and VM.
The namespace comes from the request path. Top-level bindings share the candidate
VM's globals; `ui.page().render(root)` selects the visible UI. No main function is
required. Typed helper functions and the older main-returning-View form also work.
## Code CRUD
- Read: `GET http://{address}/v1/namespaces/{name}/code`
- Create or update: `PUT http://{address}/v1/namespaces/{name}/code`
- Delete: `DELETE http://{address}/v1/namespaces/{name}/code`
Send PUT bodies as `text/plain; charset=utf-8`.
```endor
{example}```
## Live Console session
`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)` 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. 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
execution. On runtime error earlier statements in that input may remain live, but
its pending render is discarded; previous displayed page is preserved.
Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes,
4096 retained visual objects and 64 retained pages. Aster collects unreachable objects/cycles between inputs; there is no fixed UI pool. No VM handles cross to the window.
A newer accepted complete-source PUT or code DELETE invalidates queued Console inputs. A running input settles before a queued PUT candidate is built; code DELETE revokes that runtime immediately.
A successful PUT retains its fresh VM as the live program for Console and GUI events;
a failed PUT preserves the previous program. DELETE releases the live context.
GET /code returns the source of the displayed snapshot: exact submitted source for
PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind
identifies source-snapshot versus repl-transcript; a transcript is not a replay log.
The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint
still accepts one isolated expression in a fresh VM without ui or previous bindings.
It does not publish a page or change revision. Console does not use this endpoint.
All execution endpoints share the eight-entry queue. These are in-process bounded
host executions, not a complete untrusted-code sandbox or a process-isolation design.
## Managed visual types
`Object`, `Type`, `Function`, `Task`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`,
`Spacer`, `Row`, `Column`, `Button`, `Page`, `Mount`, and `Event` are managed nominal
Type objects. Use `page = Page()` and `label = TextBlock("Hello")` in Endor source.
The existing ui constructors return these same typed objects. `page.type`,
`Page.base`, `Page.name`, `isInstance(page, Block)` and `isSubtype(Page, Block)`
expose actual type relations. Types, bases, instances and native constructors live
in the same GC graph. Ordinary source classes support `class Child: Base`, typed field
declarations, explicit first `self` parameters, positional field construction and
member assignment. Inherited methods bind the actual receiver; overridden methods
are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Explicit traits and source parameter/return annotation checks are enabled; declared-field construction/write checks are enabled alongside source function checks; complete initialization and static/effect typing remain pending. Native Block/Page methods use the same type method tables and binding path.
Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Full application-exit notifications remain pending.
ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor.
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; source measurement/arrangement now repeats until constraints and geometry settle; additional allocation policies remain pending; ScrollBlock scrolling is described below.
Source layout solves repeat in one read-only call until constraints/sizes/bounds are unchanged, reusing one bytecode frame. Source-arranged changed dimensions become exact per-axis constraints for this solve; source container measurements differing from native results constrain content using the same container policy. Selected limits reset on later inputs/resizes. Nonconsecutive repeated states report a cycle. No fixed pass/hop cutoff is used; all passes share the 1000000-instruction/128-frame budget and budget failure is explicit. Measurement clears provisional Mount geometry, and only complete settled frames publish. ui.stats().layoutPasses reports work; source/cache counters include all passes. VM annotation checks distinguish validation-only from content reads, preserving Mount-typed independent measurement caching; actual Mount reads still bypass conservatively. Resumable solving remains pending. See examples/layout-negotiation.endor.
TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. Resumable solving and broad fractional-DPI validation remain pending. See examples/wrapped-text.endor.
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
ScrollBlock(width,height,child) is a ContainerBlock with managed viewport dimensions and independent per-Mount offsets. mount.scrollTo(x,y), scrollBy(dx,dy), scrollPosition() and scrollIntoView() operate on actual occurrences. Position snapshots expose x/y/maxX/maxY from committed layout; relative requests accumulate and offsets clamp after content/viewport changes. Detached Mounts and read-only rendering mutations are rejected. scrollIntoView and requestFocus adjust nested scrollable ancestors. Native/source paint, focus outlines, hit testing and pointer selection share translated geometry and ancestor clips. Remounting starts a new offset. Paint x/y may be signed after scrolling; dimensions/radii remain nonnegative and coordinate budgets still apply. See examples/scroll.endor. Wheel chooses one eligible ancestor (not per-axis split); native scrollbar dragging, touch inertia and PageUp/PageDown remain pending.
Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Other kinds must omit delta. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above.
ui.after(owner,key,milliseconds,callback) returns Task. The zero-argument callback runs on the owning VM worker after at least the integer 0..86400000 ms delay and must return Unit. Idle polling can add up to 100 ms; execution load may delay delivery further. Task.cancel/active/status expose cancellation, eligibility and pending/running/completed/cancelled/failed state. One owner/key replaces the prior pending task. Owner scopes and callback captures are managed strong edges; scheduler and owner links are weak. ui is an application owner. Hidden live objects continue; Page close cancels its and its Mount tasks, even close/reopen within one input. Page movement preserves Page tasks but replaces Mounts. Window close cancels its Pages/tasks; reset/DELETE/program replacement discard old scheduler/VM and generation checks reject stale publication. Failed PUT preserves old tasks. Callback failure stops the domain and cancels remaining tasks; earlier writes do not roll back. Read-only rendering cannot schedule/cancel. Limits: 64 pending per owner, 1024 per runtime, 64 delivered per poll, 128 frames/100000 instructions per callback. Providers support timers, asynchronous text-file reads and HTTP(S) text GET; await is supported in ui.spawn calls.
Source function parameter annotations are checked before the body and return annotations before returning. Object accepts any live value; Unit accepts nil; Number, String/Text and Bool match scalar kinds. Function includes source/native functions and bound methods, while callable class definitions remain Type. View is a compatibility alias for Block. Other names resolve actual Type/Trait objects in the function's globals then captured builtins at each call. The method's own class uses the receiver's actual owning definition, preserving old bound methods after REPL redefinition. Other type names are not frozen at definition. UnknownAnnotation and TypeMismatch report failures; bad returns do not roll back earlier writes. Declared-field construction and set_attr writes are also checked, as described below. Static inference, generic and static effect typing remain pending.
Declared fields are checked at construction and set_attr writes through the same enabled type-check switch. Native subclass fields are validated before native allocation/opening effects. Failure preserves the field; declared deletion is rejected. Inherited fields use their declaring definition/environment, the declaring class name keeps its actual identity after REPL redefinition, and other names resolve dynamically in declaration globals/builtins. Shared Block forwarding selects the current content type/storage. This does not scan old objects or revalidate all referring fields after shared-reference changes; trusted payload/storage/attach operations and complete initialization remain host responsibilities. Static/effect typing and local/captured type annotations remain pending. See examples/field-contracts.endor.
Native methods now publish positional CallArity intervals, checked by the VM before invocation. Source defaults and bound receivers participate in the same query. Traits validate acceptance of their canonical count including self and reject native methods without metadata. Event listeners/onClick require Function metadata accepting zero arguments or one Event, preferring Event if both work. Binding computes and Task callbacks require zero-argument acceptance before registration/replacement. Generic Type constructor reflection and complete native signature coverage remain separate gaps. See examples/native-signatures.endor.
Native CallSignature checks resolved parameter/return types for visual measure/arrange/paint/hitTest, Point queries, Window/Block/Container/Page/Mount/Task/Event/Subscription methods and ObjectList.at. Signature definitions are traced managed edges. Parameters fail before dispatch; final results delegated to source/native functions must satisfy all pending native return contracts, with rooted definitions and the same frame/instruction budgets. Wrong returns release scoped results but do not undo earlier writes. CallEffect ReadOnly allows result allocation; Delegated allows a read-only native wrapper while delegated targets keep the same VM read-only/budget scope (ui.present); Mutating/External calls are rejected before invocation during read-only execution. Block.click returns its generated Event; Block.dispatch returns the delivered Event. Dispatch/click and lifecycle handler contracts permit External effects; the default no-op does not constrain native overrides to ReadOnly. Existing heap barriers still apply, and this does not sandbox trusted native I/O. CallType unions preserve every branch definition. Page.open accepts Window or nil; page.window returns Window or nil; Block.on accepts Bool or String for its optional phase, while phase names retain domain validation. Mount relation queries are nullable, and bounds() is a real Bounds object usable by Point.inside. Native union metadata does not add Endor union syntax. Source method overrides now enforce parameter contravariance, return covariance and accepted-count preservation at class creation/method replacement, with call-time rechecks for dynamic annotation names. Ancestor replacement checks overriding descendants atomically. Unknown forward names may defer until invocation; known incompatibilities reject before effects. Known effects may only shrink; conservative source/call-graph ReadOnly proofs supplement runtime barriers. See examples/method-contracts.endor. Public ui functions, captured state methods and native factories publish signatures after the registry exists. Factories check the Type receiver/general result/effect and split native options from requested-class fields before validating both; omitted Rect/Text options and Row/Column variable children cannot absorb subclass fields. Event/ObjectList keep dedicated member/field checks. Generic Type reflection still has no single fixed constructor signature. See examples/constructor-contracts.endor. Current global/builtins/closure/receiver call graphs may prove ReadOnly without execution; unresolved targets/results, shared writes and Delegated targets remain unknown. Unknown legacy metadata remains unknown. See examples/visual-protocols.endor and examples/layout-negotiation.endor.
Trait definitions Visual, EventTarget, Children and Presentable are managed nominal capabilities. ui.implement(type,capability) explicitly declares an implementation after required-method/positional-arity/type/known-native-effect checks; ui.implements(type,capability) and ui.supports(value,capability) query it. Block declares Visual/EventTarget; ContainerBlock declares Children, inherited by Page. Same-name methods alone never declare an implementation. Source subclass construction and host method replacement validate inherited/affected descendant contracts. Source/native positional intervals are checked, and missing native arity metadata prevents a capability declaration; source parameter/return annotations are checked at invocation. These capabilities use typed signatures: registration checks parameter contravariance, return covariance and known native effects. Actual calls recheck dynamic names and final results across delegation, with rooted return definitions. Explicit typed method annotations remain checked even if a different host disables global source checks; concrete implementations keep wider inputs and optional convenience forms. General source effects and formal trait syntax remain pending. EventTarget requires canonical on/dispatch/onEvent arities 3/2/1 after self; native optional convenience forms remain.
For a plain object explicitly implementing Presentable, present(self, window: Window): Block is invoked by ui.present(value) or ui.open(value), using the current managed Window and the same VM budget. The result must be an actual Block. Blocks pass through directly; unregistered values retain the summary fallback. Source views retain their business object via managed fields/callbacks/bindings, as in examples/presentable.endor. Viewing function definitions does not itself execute them. Rendering methods still run read-only with nominal result checks.
Block.bind(property, compute) binds an existing managed Block field to a zero-argument function and returns Subscription (disconnect/isConnected). This includes source-defined fields and native TextBlock.text/size/color, RectBlock.width/height/radius/color, Spacer.size and container padding/gap. Native fields can also be assigned directly. The target content owns compute/captures; tokens and host registry are weak. Rebinding disconnects the old connection; disconnect keeps the last value. Actual read dependencies are tracked at object granularity, including conservative global reads; no explicit source-list parameter is required. Hidden retained Blocks update at successful input/event stabilization boundaries before all windows publish. Computes are read-only for existing objects and may allocate results. They have 100000 instructions/128 frames each, 128 active bindings per Block and a 10000-computation refresh limit that errors rather than truncates propagation. Binding failure stops the live VM and preserves published pictures. Non-Block targets, explicit dependency lists and general type/effect checking remain pending. See examples/property-bindings.endor.
Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close/tab-detach/tab-drop and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. tab-drop additionally requires tab_drop={{window,layout_version,index}}, where index is a published destination gap from 0 through its tab count (at most 64). Both source and destination orders must still match their published frames. tab-detach alone accepts optional screen_position=[x,y] as signed integer physical desktop coordinates within +/-1000000; other kinds must omit it. All spatial/tab actions validate session and layout_version before dispatch. Resource window-close uses mount=0 and no points; it validates the live window/session rather than its old layout version. The window
sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same
managed click/focus methods used by code. Events appear in Network, not Console source history.
ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or "horizontal". The default preserves two-axis unconstrained content. Vertical restricts content width to the actual occurrence viewport minus padding; horizontal restricts height. Source-arranged viewport dimensions are used in subsequent measurement. The disabled scroll axis has zero offset/limit and is clamped after updates. Composition remains vertical; a Row child can arrange horizontal content. See examples/scroll-axis.endor.
ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing.
Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below.
Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation remains unfinished.
When an occurrence's cross-axis constraint changes during settling, its preferred main-axis size is invalidated and remeasured. Any obsolete native flex allocation on that axis is removed for the replacement measurement, so narrowing a nested Column can increase wrapped text height. Source arrange allocations remain explicit. Preferred-size presence and values participate in convergence/cycle detection.
The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker currently resumes Pending immediately after yielding its OS time slice. Parking the solve across worker turns, scheduling other work and handling newer inputs before completion are not implemented yet. Native callbacks are not preempted. Only the completed settled frame is published.
source_layout::start now returns either Complete(Draft) or Pending(Work). The host-owned Work retains the unpublished frame, derived runtime/cache snapshots, convergence plan and prepare root without borrowing caller frame/cache variables. Work::resume consumes the saved job and advances the same VM continuation; Work::cancel closes that continuation and releases the prepare root without merging a draft. A second start is rejected while any read-only execution is active. Every pending Work must be resumed or explicitly cancelled with its owning VM; dropping it is not cancellation. Synchronous ReplSession operations drain the same saved refresh state. The worker parks refresh jobs; cancellation restores still-pending Mount preparation.
UiBridge::begin_layout_frame/resume_layout_frame now expose PendingLayout around the source job. This bridge-level continuation also owns its target window, page snapshot, tab projection, viewport restoration and retained lifecycle hook root. Only the Complete branch projects scroll/focus, captures dependency versions, merges derived caches, commits Mount geometry and runs deferred hooks. Resume restores the saved window context, and failure releases hook roots. ReplSession uses this protocol through its saved refresh state. Viewport refresh supports cross-worker-turn scheduling and prepared-Mount rollback; callers must not drop a pending bridge layout as a substitute for cancellation.
Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; a pending job shortens the next worker wait to 1 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests first drain the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; initial layout parks across worker turns. Explicit REPL/event refresh parks across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path.
Runtime mutation entry points reject direct calls while refresh work is pending, before executing source or changing metadata; task polling defers. The API worker drains the saved refresh before accepting those mutations, keeping the runtime usable. Incremental refresh keeps the prior complete document map until a stable pass, including across window traversal boundaries where no VM read-only frame is currently active.
New viewport targets now coalesce pending refresh work. The runtime cancels only an unfinished bridge layout, restores its Mount checkpoint, preserves completed per-window drafts/callback effects and restarts traversal with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects.
Cancellation checkpoints own explicit VM roots until restored or released. Initial presentations may have no mount dictionary; cancellation restores that absence too. Saved metadata values retain object-valued attributes. A pending refresh can restart while keeping already completed window drafts; no onMount/onUnmount callback is replayed merely because the viewport target changed. Mount-owned tasks are cancelled immediately before an actual onUnmount callback, never during abandoned preparation.
REPL/event requests retain their HTTP request and scalar result until the complete window set settles. The worker advances one stage/quantum per turn, takes current native viewport targets each time and keeps later mutations queued. GET /code keeps returning the last complete publication. Response metadata and native commands publish together after settling; no source/event callback is replayed. A running input can finish before a newer queued PUT, preserving a consistent old runtime if that candidate fails; DELETE revokes the owner and prevents its late publication. Source execution/callbacks and structural preparation and final command projection remain atomic; source paint execution can yield.
Complete-source PUT also parks an isolated candidate VM during initial layout. The worker retains its HTTP request and the prior live VM, advances one candidate stage/quantum per turn and commits only after its full window set settles. New PUT/DELETE submissions discard obsolete candidates and explicitly cancel their pending layout. Failure keeps the prior live owner; accepting a replacement alone does not revoke that owner. Candidate viewport updates use only the surviving primary shell; secondary native resource IDs and sizes still belong to the old VM until successful publication. Parsing, compilation, source execution, structural preparation and final command projection remain atomic; source paint execution can yield.
Source paint now uses a resumable read-only VM call with the same 256-instruction quantum and a cumulative 1,000,000-instruction budget. The pending job owns the unpublished frame, visual snapshot, decoded command buffer and prepare root. The refresh worker resumes paint before advancing to another window and publishes only the full stable window set. New viewport targets cancel unfinished paint, discard partial commands and force that window to refresh. Already completed layout/Mount geometry and lifecycle callbacks remain committed; cancelling paint does not replay or undo them. Failure/discard explicitly closes the read-only continuation and releases its roots. paintSlices reports the most recently completed paint solve (1 when the solve completes within its first quantum); paintCancels counts viewport replacements that closed pending paint. Layout checkpoint rollback remains limited to unfinished layout preparation. Native callback bodies, driver preparation, command decoding/projection and rasterization remain atomic.
Layout and paint drivers now prepare one occurrence at a time. Their fixed-size outer bytecode repeats on the same VM frame; a short-lived helper retains only the current call, its bounds/arguments and collector. The helper is released before the next occurrence. Layout keeps bottom-up measure, top-down arrange and the same settling loop. Paint advances even intrinsic method classification through VM quanta. The weak occurrence list and native scratch/output data remain proportional to the actual tree, but preparation no longer allocates managed callables/Bounds for the whole tree before the first slice. All helpers share the original read-only scope and cumulative instruction/frame limits. Weak-list copying, structural extraction, settling and final projection remain bounded atomic native work.
ui.stats().dependencyInvalidations counts observed layout/paint dependency version mismatches. lastInvalidDependency records the phase, object kind/identity and old/current versions without rendering its fields or retaining it. An input changing its globals is an expected invalidation; compare idle document versions before diagnosing a refresh loop.
ui.readText(owner,key,path[,callback]) returns an owner-scoped Task and reads a regular UTF-8 file off the VM thread. Paths use native filesystem syntax; absolute paths are recommended. The optional callback takes zero arguments and must return Unit; it runs only on successful read and can use task.result(). Without a callback, poll status then read result from code. result() is available while a success callback is running or after completion (timers return Unit); error() returns an empty String until failed, then the provider/callback error. A provider failure marks the task failed and skips its callback without stopping the VM; a source callback failure still stops the shared domain. Owner/key replacement, explicit cancellation, close/reset/VM discard and GC drop the provider lease, remove queued completion and reject late delivery. In-progress OS reads may finish, but cannot deliver after cancellation. Only the owning VM worker updates managed results and rechecks owner/key identity. No VM handles enter provider threads beyond opaque weak identities.
The lazy shared pool has four threads and a 64-job queue. Each runtime permits 16 outstanding I/O requests (one temporary replacement reservation), and each read is capped at 1 MiB by metadata plus actual streaming length; non-regular files, non-UTF-8 data and over-limit content fail rather than truncate. These limits do not constitute a full filesystem sandbox. The provider returns text in the same managed Task, not a second application-state store. Task await is supported through ui.spawn; see the await contract below. See examples/async-file.endor; from REPL call reader.read("/absolute/path.txt") to update its actual TextBlock, even when that object is hidden but retained.
ui.fetchText(owner,key,url[,callback]) performs an asynchronous HTTP(S) GET using the same Task, owner/key replacement and success-only callback protocol as readText. Example: fetcher.fetch("http://127.0.0.1:4737/agent") after loading examples/async-network.endor. The example retains the complete result in fetcher.response and displays completion status; it does not put arbitrary network content into a TextBlock, whose own 16 KiB limit is independent of the provider limit. Only 2xx responses produce a UTF-8 result; HTTP failures, invalid UTF-8, oversize bodies and transport errors set status() to failed with error(). It verifies HTTPS certificates, follows at most five HTTP(S) redirects, has a 10-second connect and 30-second total transfer timeout (starting when a worker takes the request), and bounds decoded text to 1 MiB without truncation. URLs are limited to 8192 bytes and must not contain credentials or control characters. This GET API does not accept request bodies, custom headers or authentication.
The current transport uses a separate system curl process per request (/usr/bin/curl on Unix, curl.exe on Windows); missing or incompatible curl fails the Task. User curlrc and URL globbing are disabled; URL data is passed through escaped stdin configuration rather than command arguments. TLS and normal system proxy behavior remain enabled. Cancellation kills an active HTTP process immediately and the provider worker reaps it; the VM thread never waits for the process. All file and HTTP requests share the per-runtime 16-task limit and global four-worker/64-job queue. In-progress file reads remain cooperatively cancelled at delivery. Network access is not a security sandbox, and this is a text GET provider rather than a general HTTP client. Task await is supported through ui.spawn; see below.
ui.spawn(owner,key,function) creates an owner-scoped cooperative Task for a zero-argument function; its returned value is retained as the Task result. Each owning VM poll resumes at most a 256-instruction quantum per selected Task, rotates runnable tasks fairly, and retains source frames in an Aster-managed coroutine edge. Calls and writes may interleave between quanta; prior writes are not rolled back on cancellation or error. Pending/running Task replacement, owner closure, GC and reset release parked frames through the same managed graph. Native calls remain atomic and all quanta share the 128-frame/100000-instruction callback limit; a native entry may run during preparation. result() is unavailable until spawn completes. A source error still stops the shared runtime. See examples/cooperative-tasks.endor. Endor await expressions now suspend these calls on a Task without replaying the operand. See the await contract below.
Inside a function run by ui.spawn, `value = await task` waits for any timer/file/network/spawn Task and resumes at the same expression with its retained result. A source function may return a Number, String, managed Object or Unit; normal source annotations still apply. Task.result() and await share the result. `ui.sleep(owner,key,milliseconds)` creates a callback-free timer Task returning Unit. Plain calls/REPL can await an already completed Task; a pending await requires a coroutine and fails explicitly rather than blocking. There is no new async function declaration: ui.spawn explicitly schedules an ordinary Endor function. See examples/await-file.endor.
A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active 1ms spin. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model.
## UI host object
Positional arguments only:
- `ui.text(value, size = 1, color = "#242422")`; integer size 1..8.
- `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)` 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.
Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000.
Use newlines between complete top-level statements. Multiline calls, function
bodies and expression continuations are passed to Aster as complete inputs.
Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded
layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an
equal share of a total 1000000-instruction allowance. Older module initialization
and main execution each retain their own 1000000-instruction allowance.
PUT waits for its worker result. Errors return 422 with diagnostics and preserve
the last successful revision. A newer accepted PUT or DELETE supersedes older
work (409); a full eight-entry queue returns 503. DELETE immediately clears the
document. GET remains available while Aster executes. The window redraws from an
owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing
and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels
with 20-pixel line height; larger sizes multiply both. Native Block methods include
setText/setColor, container append/insert/remove/replace/move, Page open/close/navigation,
and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text
binding. Mouse routing, tab chrome/multiple windows and incremental layout remain
in progress; process isolation is future work. Console retains objects
only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox.
"##,
name = namespace.name,
revision = namespace.revision,
address = API_ADDRESS,
example = include_str!("../examples/page.endor"),
)
}
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
let mut diagnostic =
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
if let Some(source) = error.downcast_ref::<frontend::SourceDiagnostic>() {
diagnostic["line"] = source.location.line.into();
diagnostic["column"] = source.location.column.into();
diagnostic["start_byte"] = source.diagnostic.span.start.into();
diagnostic["end_byte"] = source.diagnostic.span.end.into();
}
json(
StatusCode(422),
serde_json::json!({
"ok": false,
"diagnostics": [diagnostic]
}),
)
}
fn error_response(
status: StatusCode,
message: impl Into<String>,
) -> Response<std::io::Cursor<Vec<u8>>> {
json(status, serde_json::json!({ "error": message.into() }))
}
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
text(
status,
&value.to_string(),
"application/json; charset=utf-8",
)
}
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
text(status, &value, "text/markdown; charset=utf-8")
}
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let mut response = Response::from_string(value).with_status_code(status);
add_header(&mut response, "Content-Type", content_type);
add_header(
&mut response,
"Access-Control-Allow-Origin",
"http://127.0.0.1",
);
add_header(
&mut response,
"Access-Control-Allow-Methods",
"GET, PUT, POST, DELETE, OPTIONS",
);
add_header(
&mut response,
"Access-Control-Allow-Headers",
"Content-Type, X-Tessera-Origin",
);
response
}
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
if let Ok(header) = Header::from_bytes(name, value) {
response.add_header(header);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
io::Write,
net::{SocketAddr, TcpStream},
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
#[test]
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
let (_, mut picture) = endor_host::ReplSession::load(
include_str!("../examples/windows.endor"),
"app.main",
1,
150,
)
.unwrap();
picture.frame.session = 0;
for view in picture.windows.values_mut() {
view.frame.session = 0;
}
let mut fresh = endor_host::ReplSession::new(151).unwrap();
assert!(
fresh
.execute("pending=Window()\nui.window().resize(0,600)", "app.main")
.is_err()
);
assert!(fresh.accepts_window(2));
let namespace = Arc::new(RwLock::new(NamespaceState {
document: picture,
submission: 1,
runtime_session: 151,
..NamespaceState::default()
}));
let mut worker = Worker {
repl: Some((1, fresh)),
next_session: 151,
};
let notify: Notify = Arc::new(|| {});
namespace.write().unwrap().window_viewports.insert(
2,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
);
worker.refresh_viewport(&namespace, &notify);
assert_eq!(
namespace.read().unwrap().document.windows[&2].frame.session,
0
);
let response = worker.close_inactive_window(
&crate::events::Input {
window: 2,
session: 0,
layout_version: 0,
mount: 0,
kind: "window-close".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
},
1,
&namespace,
&notify,
);
assert_eq!(response.status_code(), StatusCode(200));
assert!(namespace.read().unwrap().document.windows.is_empty());
let runtime = &mut worker.repl.as_mut().unwrap().1;
assert_eq!(runtime.id, 151);
assert!(runtime.accepts_window(2));
assert_eq!(
runtime.execute("pending.id()", "app.main").unwrap().value,
"2"
);
}
struct TestServer {
server: Arc<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
impl TestServer {
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
let mut stream = TcpStream::connect(self.address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(10)))
.unwrap();
write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
(status, headers.to_owned(), body.to_owned())
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(api) = self.api.take() {
let _ = api.join();
}
}
}
#[test]
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"class Tracked: Page {
calls: Object
function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) }
}
calls=ui.state(0)
first=Tracked(calls)
first.title="first"
first.render(TextBlock("first"))
second=Page()
second.title="second"
second.render(TextBlock("second"))
first.activate()"#;
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
let input = {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: frame.tabs[0].id,
kind: "tab-drop".into(),
point: None,
press_point: None,
delta: None,
screen_position: None,
tab_drop: Some(crate::events::TabDrop {
window: 1,
layout_version: frame.layout_version,
index: 2,
}),
}
};
let body = serde_json::to_string(&input).unwrap();
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value");
let value: serde_json::Value = serde_json::from_str(&response.2).unwrap();
assert_eq!(value["value"], "1");
assert_eq!(
namespace.read().unwrap().document.frame.tabs[1].title,
"first"
);
assert_eq!(
client
.request("POST", "/v1/namespaces/app.main/events", &body)
.0,
409
);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
fn background_failure_preserves_its_cause_after_rejected_console_input() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"class Failure {
function stop(self: Failure): Unit { ui.window().resize(0,600) }
}
failure=Failure()
page=Page()
page.render(TextBlock("preserved"))
task=ui.after(ui,"failure",0,failure.stop)"#;
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
let repl = "/v1/namespaces/app.main/repl";
assert_eq!(client.request("POST", repl, "42").0, 422);
let fault = namespace
.read()
.unwrap()
.document
.frame
.fault
.clone()
.unwrap();
assert!(fault.contains("window size"), "{fault}");
assert!(fault.contains("stop"), "{fault}");
assert_eq!(client.request("POST", repl, "42").0, 422);
assert_eq!(
namespace.read().unwrap().document.frame.fault.as_ref(),
Some(&fault)
);
}
#[test]
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let repl = "/v1/namespaces/app.main/repl";
let code = "/v1/namespaces/app.main/code";
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
assert_eq!(created.0, 200, "{}", created.2);
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
assert_eq!(created["rendered"], false);
assert_eq!(created["revision"], 0);
let read: serde_json::Value =
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
assert_eq!(read["value"], created["value"]);
assert_eq!(
client.request("POST", repl, "ui.page().render(title)").0,
200
);
let snapshot = client.request("GET", code, "");
assert!(
snapshot
.1
.to_lowercase()
.contains("x-tessera-source-kind: repl-transcript")
);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("DELETE", repl, "").0, 200);
assert_eq!(client.request("GET", code, "").2, snapshot.2);
assert_eq!(client.request("POST", repl, "title").0, 422);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
assert_eq!(
client
.request("PUT", code, include_str!("../examples/page.endor"))
.0,
200
);
assert_eq!(client.request("POST", repl, "retained").0, 422);
assert_eq!(namespace.read().unwrap().revision, 2);
}
#[test]
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let notifications = Arc::new(AtomicUsize::new(0));
let counter = notifications.clone();
let notify: Notify = Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../examples/page.endor");
let (status, _, guide) = client.request("GET", "/agent", "");
assert_eq!(status, 200);
assert!(guide.contains("let myui = ui.page()"));
let (status, _, body) = client.request("PUT", route, example);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["revision"],
1
);
let (status, headers, source) = client.request("GET", route, "");
assert_eq!(status, 200);
assert!(headers.to_lowercase().contains("x-tessera-revision: 1"));
assert_eq!(source, example);
let (status, _, body) =
client.request("PUT", route, "function main(): View { return missing }");
assert_eq!(status, 422, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["diagnostics"][0]["line"],
1
);
assert_eq!(client.request("GET", route, "").2, example);
let second = example.replace("Hello Endor", "Second");
assert_eq!(client.request("PUT", route, &second).0, 200);
assert_eq!(namespace.read().unwrap().revision, 2);
assert_eq!(client.request("DELETE", route, "").0, 200);
assert_eq!(client.request("GET", route, "").2, "");
assert!(namespace.read().unwrap().document.frame.commands.is_empty());
assert_eq!(client.request("PUT", route, example).0, 200);
assert_eq!(namespace.read().unwrap().revision, 4);
assert_eq!(
client
.request("PUT", "/v1/namespaces/other/code", example)
.0,
404
);
assert_eq!(notifications.load(Ordering::SeqCst), 4);
}
#[test]
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
let (runtime, document) = endor_host::ReplSession::load(
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
"app.main",
1,
277,
)
.unwrap();
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
submission: 2,
runtime_session: runtime.id,
..NamespaceState::default()
}));
let mut worker = Worker {
repl: Some((1, runtime)),
next_session: 277,
};
let notify: Notify = Arc::new(|| {});
worker.refresh_viewport(&namespace, &notify);
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
assert_eq!(
worker
.repl
.as_mut()
.unwrap()
.1
.execute("retained.value", "app.main")
.unwrap()
.value,
"9"
);
{
let mut current = namespace.write().unwrap();
current.submission = 3;
worker.preserve_live_owner(&current);
}
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
namespace.write().unwrap().clear().unwrap();
worker.refresh_viewport(&namespace, &notify);
assert!(worker.repl.is_none());
}
#[test]
fn http_delete_remains_available_and_invalidates_queued_put() {
use std::{
sync::{Mutex, mpsc},
time::Instant,
};
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let release_rx = Mutex::new(release_rx);
let calls = AtomicUsize::new(0);
let notify: Notify = Arc::new(move || {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
entered_tx.send(()).unwrap();
release_rx.lock().unwrap().recv().unwrap();
}
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// Unblock even if a test assertion fails, before scoped threads join.
struct Release(mpsc::Sender<()>);
impl Drop for Release {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
thread::scope(|scope| {
let release = Release(release_tx);
let client = &client;
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../examples/page.endor");
let first = scope.spawn(move || client.request("PUT", route, example));
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let pending = scope.spawn(move || client.request("PUT", route, example));
let deadline = Instant::now() + Duration::from_secs(10);
while namespace.read().unwrap().submission < 2 {
assert!(Instant::now() < deadline, "second PUT was not admitted");
thread::sleep(Duration::from_millis(5));
}
assert_eq!(client.request("GET", route, "").2, example);
assert_eq!(client.request("DELETE", route, "").0, 200);
drop(release);
assert_eq!(first.join().unwrap().0, 200);
assert_eq!(pending.join().unwrap().0, 409);
assert_eq!(client.request("GET", route, "").2, "");
assert_eq!(namespace.read().unwrap().revision, 2);
});
}
#[test]
fn extracts_namespace_only_from_code_routes() {
assert_eq!(
namespace_from_path("/v1/namespaces/app.main/code"),
Some("app.main")
);
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
}
#[test]
fn guide_contains_only_code_crud_routes() {
let guide = agent_guide(&NamespaceState::default());
assert!(guide.contains("Code CRUD"));
assert!(guide.contains("let myui = ui.page()"));
assert!(!guide.contains("export scene"));
assert!(guide.contains("PUT http://127.0.0.1:4737"));
assert!(!guide.contains("display_list"));
}
}