Add bounded runtime actor and live PTY pane
CI / rust (push) Successful in 3m26s

This commit is contained in:
2026-08-31 16:32:50 -07:00
parent ab7543b9c6
commit 32d190c6c6
15 changed files with 1111 additions and 39 deletions
Generated
+8
View File
@@ -761,6 +761,14 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
[[package]]
name = "lumbridge-runtime"
version = "0.0.1"
dependencies = [
"lumbridge-pty",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "lumbridge-storage" name = "lumbridge-storage"
version = "0.0.1" version = "0.0.1"
+1
View File
@@ -4,6 +4,7 @@ members = [
"crates/lumbridge-buzz", "crates/lumbridge-buzz",
"crates/lumbridge-core", "crates/lumbridge-core",
"crates/lumbridge-pty", "crates/lumbridge-pty",
"crates/lumbridge-runtime",
"crates/lumbridge-storage", "crates/lumbridge-storage",
] ]
exclude = ["spikes"] exclude = ["spikes"]
+4 -3
View File
@@ -19,9 +19,10 @@ as installable binaries; building from source will remain supported.
This repository is in architecture and vertical-slice phase. The installable This repository is in architecture and vertical-slice phase. The installable
binary is still a scaffold, while the isolated native UI spikes now exercise an binary is still a scaffold, while the isolated native UI spikes now exercise an
interactive six-surface workspace and the root workspace contains the first interactive six-surface workspace and the root workspace contains the first
bounded local PTY boundary. We are still validating terminal emulation, the bounded local PTY and runtime-actor boundaries. The GPUI slice renders one real
session runtime, ACP integration, packaging, and usage-data contracts before a actor-owned PTY beside five deterministic surfaces. We are still validating
large implementation. terminal emulation, standalone runtime IPC/durability, ACP integration,
packaging, and usage-data contracts before a large implementation.
## Product shape ## Product shape
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "lumbridge-runtime"
description = "Session actor and process ownership boundary for Lumbridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
lumbridge-pty = { path = "../lumbridge-pty" }
thiserror = "2.0"
[lints]
workspace = true
+558
View File
@@ -0,0 +1,558 @@
//! Bounded session actor for Lumbridge-owned processes.
//!
//! The actor is deliberately transport-neutral. It owns one local PTY today;
//! future local IPC and remote transports can preserve the same ordered command
//! and event contract without exposing [`lumbridge_pty::PtySession`] to a UI.
#![forbid(unsafe_code)]
pub use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize};
use lumbridge_pty::{ExitStatus, OutputEvent, PtyError, PtySession};
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use thiserror::Error;
const DEFAULT_COMMAND_QUEUE: usize = 64;
const DEFAULT_EVENT_QUEUE: usize = 128;
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(10);
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
/// Process-local identity for the first runtime slice.
///
/// Durable local and remote session IDs belong in the persisted runtime
/// protocol; this value only correlates events within the current process.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct RuntimeSessionId(u64);
impl RuntimeSessionId {
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
/// A command accepted by the single-owner runtime actor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeCommand {
Input(Vec<u8>),
Resize(TerminalSize),
CloseInput,
Shutdown,
}
/// The operation associated with a redacted runtime fault.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeOperation {
Input,
Resize,
Output,
Wait,
Shutdown,
}
/// An ordered event emitted by the runtime actor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RuntimeEvent {
Started {
session_id: RuntimeSessionId,
process_id: Option<u32>,
},
Output {
session_id: RuntimeSessionId,
sequence: u64,
bytes: Vec<u8>,
},
InputClosed {
session_id: RuntimeSessionId,
},
Exited {
session_id: RuntimeSessionId,
status: ExitStatus,
},
Fault {
session_id: RuntimeSessionId,
operation: RuntimeOperation,
message: String,
},
}
/// Bounded queue and polling settings for a runtime actor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RuntimeActorOptions {
pub command_queue: NonZeroUsize,
pub event_queue: NonZeroUsize,
pub poll_interval: Duration,
}
impl Default for RuntimeActorOptions {
fn default() -> Self {
Self {
command_queue: NonZeroUsize::new(DEFAULT_COMMAND_QUEUE).expect("non-zero constant"),
event_queue: NonZeroUsize::new(DEFAULT_EVENT_QUEUE).expect("non-zero constant"),
poll_interval: DEFAULT_POLL_INTERVAL,
}
}
}
#[derive(Debug, Error)]
pub enum RuntimeActorError {
#[error("failed to start the PTY session: {0}")]
Start(#[source] PtyError),
#[error("the runtime command queue is full")]
CommandQueueFull,
#[error("the runtime actor is disconnected")]
Disconnected,
#[error("timed out waiting for a runtime event")]
EventTimeout,
#[error("the runtime actor thread panicked")]
ThreadPanicked,
#[error("the runtime actor poll interval must be greater than zero")]
InvalidPollInterval,
}
/// UI-side owner of one runtime actor and its bounded channels.
pub struct RuntimeActor {
session_id: RuntimeSessionId,
process_id: Option<u32>,
commands: Option<SyncSender<RuntimeCommand>>,
events: Option<Receiver<RuntimeEvent>>,
thread: Option<JoinHandle<()>>,
}
impl RuntimeActor {
/// Starts a PTY synchronously, then transfers it to its actor thread.
///
/// # Errors
///
/// Returns [`RuntimeActorError::Start`] if the PTY or child cannot start.
pub fn spawn(
command: CommandConfig,
pty_options: PtyOptions,
actor_options: RuntimeActorOptions,
) -> Result<Self, RuntimeActorError> {
if actor_options.poll_interval.is_zero() {
return Err(RuntimeActorError::InvalidPollInterval);
}
let session = PtySession::spawn(command, pty_options).map_err(RuntimeActorError::Start)?;
let process_id = session.process_id();
let session_id = RuntimeSessionId(NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed));
let (command_sender, command_receiver) =
mpsc::sync_channel(actor_options.command_queue.get());
let (event_sender, event_receiver) = mpsc::sync_channel(actor_options.event_queue.get());
let thread = thread::Builder::new()
.name(format!("lumbridge-runtime-{}", session_id.get()))
.spawn(move || {
run_actor(
session_id,
session,
&command_receiver,
&event_sender,
actor_options.poll_interval,
);
})
.map_err(|error| RuntimeActorError::Start(PtyError::Io(error)))?;
Ok(Self {
session_id,
process_id,
commands: Some(command_sender),
events: Some(event_receiver),
thread: Some(thread),
})
}
#[must_use]
pub const fn session_id(&self) -> RuntimeSessionId {
self.session_id
}
#[must_use]
pub const fn process_id(&self) -> Option<u32> {
self.process_id
}
/// Enqueues without blocking the UI thread.
///
/// # Errors
///
/// Reports bounded backpressure or actor disconnection without returning
/// command contents in the error value.
pub fn try_send(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> {
let commands = self
.commands
.as_ref()
.ok_or(RuntimeActorError::Disconnected)?;
commands.try_send(command).map_err(|error| match error {
TrySendError::Full(_) => RuntimeActorError::CommandQueueFull,
TrySendError::Disconnected(_) => RuntimeActorError::Disconnected,
})
}
/// Polls the next ordered runtime event without blocking.
///
/// # Errors
///
/// Returns [`RuntimeActorError::Disconnected`] after the actor stops and all
/// queued events have been consumed.
pub fn try_recv(&self) -> Result<Option<RuntimeEvent>, RuntimeActorError> {
let events = self
.events
.as_ref()
.ok_or(RuntimeActorError::Disconnected)?;
match events.try_recv() {
Ok(event) => Ok(Some(event)),
Err(TryRecvError::Empty) => Ok(None),
Err(TryRecvError::Disconnected) => Err(RuntimeActorError::Disconnected),
}
}
/// Waits for the next ordered runtime event.
///
/// # Errors
///
/// Reports timeout or actor disconnection.
pub fn recv_timeout(&self, timeout: Duration) -> Result<RuntimeEvent, RuntimeActorError> {
let events = self
.events
.as_ref()
.ok_or(RuntimeActorError::Disconnected)?;
match events.recv_timeout(timeout) {
Ok(event) => Ok(event),
Err(RecvTimeoutError::Timeout) => Err(RuntimeActorError::EventTimeout),
Err(RecvTimeoutError::Disconnected) => Err(RuntimeActorError::Disconnected),
}
}
/// Requests graceful actor shutdown and waits for its thread.
///
/// # Errors
///
/// Reports queue disconnection or an actor-thread panic.
pub fn shutdown(mut self) -> Result<(), RuntimeActorError> {
self.try_send(RuntimeCommand::Shutdown)?;
self.commands.take();
self.events.take();
join_thread(&mut self.thread)
}
}
impl Drop for RuntimeActor {
fn drop(&mut self) {
if let Some(commands) = &self.commands {
let _ = commands.try_send(RuntimeCommand::Shutdown);
}
// Disconnect both channels before joining. This releases an actor that
// is applying event backpressure and lets its PtySession clean up.
self.commands.take();
self.events.take();
let _ = join_thread(&mut self.thread);
}
}
fn join_thread(thread: &mut Option<JoinHandle<()>>) -> Result<(), RuntimeActorError> {
let Some(thread) = thread.take() else {
return Ok(());
};
thread.join().map_err(|_| RuntimeActorError::ThreadPanicked)
}
fn run_actor(
session_id: RuntimeSessionId,
mut session: PtySession,
commands: &Receiver<RuntimeCommand>,
events: &SyncSender<RuntimeEvent>,
poll_interval: Duration,
) {
if send_event(
events,
RuntimeEvent::Started {
session_id,
process_id: session.process_id(),
},
)
.is_err()
{
return;
}
let mut output_sequence = 0_u64;
loop {
loop {
match commands.try_recv() {
Ok(command) => {
if !handle_command(session_id, &mut session, events, command) {
return;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return,
}
}
match session.recv_output_timeout(poll_interval) {
Ok(OutputEvent::Data(bytes)) => {
output_sequence += 1;
if send_event(
events,
RuntimeEvent::Output {
session_id,
sequence: output_sequence,
bytes,
},
)
.is_err()
{
return;
}
}
Ok(OutputEvent::Eof) => match session.wait() {
Ok(status) => {
let _ = send_event(events, RuntimeEvent::Exited { session_id, status });
return;
}
Err(error) => {
let _ = send_fault(events, session_id, RuntimeOperation::Wait, &error);
return;
}
},
Ok(OutputEvent::ReadFailed(error)) => {
let _ = send_event(
events,
RuntimeEvent::Fault {
session_id,
operation: RuntimeOperation::Output,
message: error.message,
},
);
return;
}
Err(PtyError::OutputTimeout) => match session.try_wait() {
Ok(Some(status)) => {
let _ = send_event(events, RuntimeEvent::Exited { session_id, status });
return;
}
Ok(None) => {}
Err(error) => {
let _ = send_fault(events, session_id, RuntimeOperation::Wait, &error);
return;
}
},
Err(error) => {
let _ = send_fault(events, session_id, RuntimeOperation::Output, &error);
return;
}
}
}
}
fn handle_command(
session_id: RuntimeSessionId,
session: &mut PtySession,
events: &SyncSender<RuntimeEvent>,
command: RuntimeCommand,
) -> bool {
let result = match command {
RuntimeCommand::Input(bytes) => session
.write_all(&bytes)
.map_err(|error| (RuntimeOperation::Input, error)),
RuntimeCommand::Resize(size) => session
.resize(size)
.map_err(|error| (RuntimeOperation::Resize, error)),
RuntimeCommand::CloseInput => {
session.close_input();
return send_event(events, RuntimeEvent::InputClosed { session_id }).is_ok();
}
RuntimeCommand::Shutdown => {
match session.terminate() {
Ok(status) => {
let _ = send_event(events, RuntimeEvent::Exited { session_id, status });
}
Err(error) => {
let _ = send_fault(events, session_id, RuntimeOperation::Shutdown, &error);
}
}
return false;
}
};
if let Err((operation, error)) = result {
return send_fault(events, session_id, operation, &error).is_ok();
}
true
}
fn send_event(
events: &SyncSender<RuntimeEvent>,
event: RuntimeEvent,
) -> Result<(), mpsc::SendError<RuntimeEvent>> {
events.send(event)
}
fn send_fault(
events: &SyncSender<RuntimeEvent>,
session_id: RuntimeSessionId,
operation: RuntimeOperation,
error: &PtyError,
) -> Result<(), mpsc::SendError<RuntimeEvent>> {
send_event(
events,
RuntimeEvent::Fault {
session_id,
operation,
message: error.to_string(),
},
)
}
#[cfg(test)]
mod tests {
use super::{
RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent,
};
use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize};
use std::time::{Duration, Instant};
const TEST_TIMEOUT: Duration = Duration::from_secs(3);
fn shell(script: &str) -> CommandConfig {
CommandConfig::new("/bin/sh")
.expect("valid synthetic shell")
.args(["-c", script])
}
fn actor(script: &str) -> RuntimeActor {
RuntimeActor::spawn(
shell(script),
PtyOptions::default(),
RuntimeActorOptions::default(),
)
.expect("spawn runtime actor")
}
fn events_until_exit(actor: &RuntimeActor) -> Vec<RuntimeEvent> {
let deadline = Instant::now() + TEST_TIMEOUT;
let mut events = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "timed out waiting for actor exit");
let event = actor.recv_timeout(remaining).expect("runtime event");
let exited = matches!(event, RuntimeEvent::Exited { .. });
events.push(event);
if exited {
return events;
}
}
}
#[test]
fn emits_started_ordered_output_and_exit() {
let actor = actor("printf 'first\\n'; printf 'second\\n'; exit 7");
let session_id = actor.session_id();
let events = events_until_exit(&actor);
assert!(matches!(
events.first(),
Some(RuntimeEvent::Started {
session_id: started,
..
}) if *started == session_id
));
let output = events
.iter()
.filter_map(|event| match event {
RuntimeEvent::Output {
sequence, bytes, ..
} => Some((*sequence, bytes.as_slice())),
_ => None,
})
.collect::<Vec<_>>();
assert!(!output.is_empty());
assert!(
output.windows(2).all(|pair| pair[0].0 < pair[1].0),
"output sequence must increase"
);
let bytes = output
.iter()
.flat_map(|(_, bytes)| bytes.iter().copied())
.collect::<Vec<_>>();
let text = String::from_utf8_lossy(&bytes).replace('\r', "");
assert!(
text.contains("first\nsecond\n"),
"unexpected output: {text:?}"
);
assert!(matches!(
events.last(),
Some(RuntimeEvent::Exited { status, .. }) if status.code == 7
));
}
#[test]
fn serializes_input_and_resize_through_commands() {
let actor = actor("IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\"");
actor
.try_send(RuntimeCommand::Resize(
TerminalSize::new(41, 101).expect("valid size"),
))
.expect("queue resize");
actor
.try_send(RuntimeCommand::Input(b"hello-actor\n".to_vec()))
.expect("queue input");
let events = events_until_exit(&actor);
let bytes = events
.iter()
.filter_map(|event| match event {
RuntimeEvent::Output { bytes, .. } => Some(bytes.as_slice()),
_ => None,
})
.flatten()
.copied()
.collect::<Vec<_>>();
let text = String::from_utf8_lossy(&bytes).replace('\r', "");
assert!(text.contains("41 101"), "unexpected output: {text:?}");
assert!(
text.contains("got:hello-actor"),
"unexpected output: {text:?}"
);
}
#[test]
fn drop_terminates_a_hung_session_without_waiting_for_output() {
let actor = actor("trap '' HUP; exec sleep 30");
let process_id = actor.process_id().expect("Unix shell has a process id");
let started = Instant::now();
drop(actor);
assert!(started.elapsed() < TEST_TIMEOUT);
#[cfg(target_os = "linux")]
{
let proc_path = format!("/proc/{process_id}");
assert!(!std::path::Path::new(&proc_path).exists());
}
}
#[test]
fn reports_timeout_without_exposing_command_contents() {
let actor = actor("sleep 1");
assert!(matches!(
actor.recv_timeout(Duration::ZERO),
Err(RuntimeActorError::EventTimeout) | Ok(RuntimeEvent::Started { .. })
));
}
#[test]
fn rejects_a_zero_poll_interval_before_spawning() {
let options = RuntimeActorOptions {
poll_interval: Duration::ZERO,
..RuntimeActorOptions::default()
};
let result = RuntimeActor::spawn(shell("exit 0"), PtyOptions::default(), options);
assert!(matches!(
result,
Err(RuntimeActorError::InvalidPollInterval)
));
}
}
+9 -2
View File
@@ -41,8 +41,8 @@ messages should be real from the beginning.
- `lumbridge`: installable application entry point. - `lumbridge`: installable application entry point.
The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, The scaffold currently contains `lumbridge-core`, `lumbridge-storage`,
`lumbridge-buzz`, `lumbridge-pty`, and the entry point. Larger runtime crates are `lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, and the entry point.
added after their architecture spikes pass. Larger runtime crates are added after their architecture spikes pass.
## Terminal path ## Terminal path
@@ -55,6 +55,13 @@ scrollback. The terminal engine turns output into immutable render snapshots and
bounded deltas for the UI. Scrollback is chunked and persisted separately from bounded deltas for the UI. Scrollback is chunked and persisted separately from
the live screen to prevent large agent transcripts from blocking input. the live screen to prevent large agent transcripts from blocking input.
The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated
thread. Bounded command and event queues serialize input, resize, close, and
shutdown against ordered raw-byte output. The GPUI slice consumes one actor
session while five surfaces retain deterministic comparison output. This actor
still runs in-process; moving the same framework-neutral contract behind local
authenticated IPC is the next durability step. See decision 0004.
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
first spike must compare a reusable terminal crate with a small first-party layer. first spike must compare a reusable terminal crate with a small first-party layer.
Correctness cases include alternate screen, bracketed paste, OSC 8 links, Kitty Correctness cases include alternate screen, bracketed paste, OSC 8 links, Kitty
+5
View File
@@ -109,6 +109,11 @@ who already have the final cargo-watch release installed.
- `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits, - `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits,
input, resize, one-chunk backpressure, hung-process termination, invalid input, resize, one-chunk backpressure, hung-process termination, invalid
dimensions, and exclusion of provider credentials from the child environment. dimensions, and exclusion of provider credentials from the child environment.
- `lumbridge-runtime` tests ordered actor output, serialized input and resize,
event polling, invalid configuration, and bounded-time cleanup of a hung PTY.
- The GPUI slice tests byte line-framing across CRLF, chunk boundaries, split
UTF-8, and invalid bytes. It renders one real actor-owned PTY and keeps five
surfaces deterministic; line framing is explicitly not VT emulation.
- The current-GPUI probe compile-checks real AccessKit element wiring and real - The current-GPUI probe compile-checks real AccessKit element wiring and real
platform input-handler installation. Unit tests cover its semantic tree and platform input-handler installation. Unit tests cover its semantic tree and
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows, UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
+8 -7
View File
@@ -83,10 +83,11 @@ fallback.
## Measurement semantics ## Measurement semantics
Both renderers consume the same deterministic six-surface action stream. Each Both renderers retain the same all-deterministic six-surface action stream for
tick invalidates six surfaces, appends one bounded line to each of the three comparison. The GPUI integration mode replaces one terminal fixture with a real
terminal fixtures, and records stable event/revision counters. The GPUI footer actor-owned PTY and leaves five deterministic surfaces running. Counters
currently reports dispatch-to-element-build p50/p95 over a bounded 256-sample separate external PTY batches/lines from total model updates. The GPUI footer
window. It is deliberately not called key-to-present or frame-present latency: reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
neither candidate exposes a reliable public cross-platform post-present is deliberately not called key-to-present or frame-present latency: neither
callback. True present latency requires an external platform probe. candidate exposes a reliable public cross-platform post-present callback. True
present latency requires an external platform probe.
+7 -4
View File
@@ -84,16 +84,19 @@ receive the same state transitions and tests.
## Decisive workload ## Decisive workload
- One deterministic tick invalidates all six surfaces and appends bounded output - In the first actor integration, one terminal pane consumes ordered output from
to the three terminal panes. Framework adapters consume the same event trace. a real local PTY while each deterministic tick updates the other five
surfaces. The all-deterministic constructor remains available for framework
comparison and replay tests.
- One pane enters and leaves `needs input` through a deterministic event. - One pane enters and leaves `needs input` through a deterministic event.
- Markdown, browser-boundary, and review panes update counters without using a - Markdown, browser-boundary, and review panes update counters without using a
web application shell. web application shell.
- The model records actions, revisions, six-surface updates, and bounded terminal - The model records actions, revisions, six-surface updates, and bounded terminal
line counts. Framework adapters label each measured timing stage explicitly; line counts. Framework adapters label each measured timing stage explicitly;
element-build timing is never presented as display-present timing. element-build timing is never presented as display-present timing.
- A later PTY adapter replaces one synthetic stream without changing the UI - The PTY actor replaces exactly one synthetic stream without changing focus or
contract. Synthetic streams stay available for repeatable performance tests. layout contracts. Synthetic streams stay available for repeatable performance
tests. Raw lines do not claim terminal-emulation fidelity.
## Hard gates ## Hard gates
@@ -0,0 +1,26 @@
# 0004: A bounded runtime actor owns each PTY
Status: accepted for the first local runtime slice.
The desktop UI never owns or directly polls `PtySession`. A single runtime
actor owns the PTY, child process, input ordering, resize ordering, output
sequence, and process-tree cleanup. Its command and event channels are bounded,
so a slow UI produces explicit backpressure instead of unbounded transcript
memory.
The first implementation runs this actor on a dedicated thread in the GPUI
process. That is an implementation step, not the final deployment boundary. The
commands and events remain free of GPUI types so they can move behind the
versioned local IPC connection when the standalone `lumbridge-runtime` process
arrives. Process-local session IDs are correlation IDs only; durable IDs are
assigned by the persisted runtime protocol later.
Runtime events contain ordered raw PTY bytes. The UI spike may line-frame plain
fixture output for display, but ANSI/VT parsing, screen state, cursor behavior,
scrollback, and terminal input modes belong to `lumbridge-terminal`. The UI must
not infer terminal semantics from raw strings.
Dropping the UI-side actor disconnects its bounded channels, releases an actor
blocked by event backpressure, terminates the process group through
`lumbridge-pty`, and joins the actor thread. Tests use only synthetic shell
fixtures and never provider credentials or real transcripts.
+5
View File
@@ -18,6 +18,11 @@ Both spikes must preserve the same information architecture:
- native Markdown editor/preview and browser placeholders; - native Markdown editor/preview and browser placeholders;
- connection, harness, usage, and burn context in the footer. - connection, harness, usage, and burn context in the footer.
GPUI also has an integration mode with one real local PTY owned by
`lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the
shared model retain the all-deterministic mode for like-for-like framework
comparison. The PTY display is plain byte line-framing, not terminal emulation.
Build independently: Build independently:
```bash ```bash
+99 -6
View File
@@ -791,6 +791,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.2" version = "0.2.2"
@@ -1432,7 +1438,7 @@ dependencies = [
"rustc_version", "rustc_version",
"toml 1.1.4+spec-1.1.0", "toml 1.1.4+spec-1.1.0",
"vswhom", "vswhom",
"winreg", "winreg 0.55.0",
] ]
[[package]] [[package]]
@@ -2977,11 +2983,29 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lumbridge-pty"
version = "0.0.1"
dependencies = [
"nix 0.28.0",
"portable-pty",
"thiserror 2.0.20",
]
[[package]]
name = "lumbridge-runtime"
version = "0.0.1"
dependencies = [
"lumbridge-pty",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "lumbridge-spike-gpui" name = "lumbridge-spike-gpui"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"gpui", "gpui",
"lumbridge-runtime",
"lumbridge-spike-model", "lumbridge-spike-model",
] ]
@@ -3193,7 +3217,7 @@ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg_aliases", "cfg_aliases 0.2.2",
"codespan-reporting", "codespan-reporting",
"half", "half",
"hashbrown 0.15.5", "hashbrown 0.15.5",
@@ -3224,6 +3248,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -3232,7 +3268,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases 0.2.2",
"libc", "libc",
] ]
@@ -3244,7 +3280,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases 0.2.2",
"libc", "libc",
] ]
@@ -3789,6 +3825,27 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix 0.28.0",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]] [[package]]
name = "postage" name = "postage"
version = "0.5.0" version = "0.5.0"
@@ -3963,7 +4020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases 0.2.2",
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
@@ -4004,7 +4061,7 @@ version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases 0.2.2",
"libc", "libc",
"once_cell", "once_cell",
"socket2", "socket2",
@@ -4783,6 +4840,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serial2"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "sha1_smol" name = "sha1_smol"
version = "1.0.1" version = "1.0.1"
@@ -4811,6 +4879,22 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -6629,6 +6713,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" version = "0.55.0"
+1
View File
@@ -9,6 +9,7 @@ publish = false
[dependencies] [dependencies]
gpui = "0.2.2" gpui = "0.2.2"
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
lumbridge-spike-model = { path = "../ui-shell-model" } lumbridge-spike-model = { path = "../ui-shell-model" }
[workspace] [workspace]
+261 -14
View File
@@ -5,9 +5,12 @@ use gpui::{
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds, App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
WindowOptions, actions, div, prelude::*, px, rgb, size, WindowOptions, actions, div, prelude::*, px, rgb, size,
}; };
use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent,
};
use lumbridge_spike_model::{ use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel, ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
SurfaceKind, WORKSPACES, ShellModel, SurfaceKind, WORKSPACES,
}; };
const BG: u32 = 0x090c12; const BG: u32 = 0x090c12;
@@ -22,6 +25,10 @@ const ACCENT: u32 = 0x68b5f8;
const ATTENTION: u32 = 0xf1b96a; const ATTENTION: u32 = 0xf1b96a;
const SUCCESS: u32 = 0x70d6a8; const SUCCESS: u32 = 0x70d6a8;
const TIMING_SAMPLE_LIMIT: usize = 256; const TIMING_SAMPLE_LIMIT: usize = 256;
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64;
const LIVE_PANE: PaneId = PaneId::CodexRuntime;
const LIVE_PTY_SCRIPT: &str = "i=0; printf 'Lumbridge runtime actor owns this PTY\\n'; while :; do printf '[pty %06d] /bin/sh · actor output\\n' \"$i\"; i=$((i+1)); sleep 1; done";
actions!( actions!(
lumbridge, lumbridge,
@@ -44,10 +51,93 @@ actions!(
struct LumbridgeShell { struct LumbridgeShell {
model: ShellModel, model: ShellModel,
timing: RenderTiming, timing: RenderTiming,
runtime: Option<RuntimeActor>,
runtime_status: LiveRuntimeStatus,
runtime_lines: PtyLineFramer,
last_runtime_sequence: u64,
root_focus: FocusHandle, root_focus: FocusHandle,
pane_focus: [FocusHandle; 6], pane_focus: [FocusHandle; 6],
} }
#[derive(Clone, Debug, Eq, PartialEq)]
enum LiveRuntimeStatus {
Starting,
Running {
session_id: u64,
process_id: Option<u32>,
},
Exited(String),
Fault(String),
}
impl LiveRuntimeStatus {
fn badge(&self) -> &'static str {
match self {
Self::Starting => "PTY STARTING",
Self::Running { .. } => "LIVE PTY",
Self::Exited(_) => "PTY EXITED",
Self::Fault(_) => "PTY FAULT",
}
}
fn detail(&self) -> String {
match self {
Self::Starting => "local · runtime actor starting".to_owned(),
Self::Running {
session_id,
process_id,
} => match process_id {
Some(process_id) => {
format!("local · runtime session {session_id} · pid {process_id}")
}
None => format!("local · runtime session {session_id}"),
},
Self::Exited(status) => format!("local · {status}"),
Self::Fault(message) => format!("local · {message}"),
}
}
const fn is_fault(&self) -> bool {
matches!(self, Self::Fault(_))
}
const fn is_terminal(&self) -> bool {
matches!(self, Self::Exited(_) | Self::Fault(_))
}
}
#[derive(Default)]
struct PtyLineFramer {
pending: Vec<u8>,
}
impl PtyLineFramer {
fn push(&mut self, bytes: &[u8]) -> Vec<String> {
self.pending.extend_from_slice(bytes);
let mut lines = Vec::new();
while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
let mut line = self.pending.drain(..=newline).collect::<Vec<_>>();
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
lines.push(String::from_utf8_lossy(&line).into_owned());
}
lines
}
fn finish(&mut self) -> Vec<String> {
if self.pending.is_empty() {
return Vec::new();
}
let mut line = std::mem::take(&mut self.pending);
if line.last() == Some(&b'\r') {
line.pop();
}
vec![String::from_utf8_lossy(&line).into_owned()]
}
}
#[derive(Default)] #[derive(Default)]
struct RenderTiming { struct RenderTiming {
pending_dispatch: Option<Instant>, pending_dispatch: Option<Instant>,
@@ -121,9 +211,36 @@ impl LumbridgeShell {
}) })
.detach(); .detach();
cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(RUNTIME_POLL_INTERVAL).await;
if this
.update(cx, |shell, cx| {
if shell.drain_runtime_events() {
cx.notify();
}
})
.is_err()
{
break;
}
}
})
.detach();
let (runtime, runtime_status) = match start_live_runtime() {
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
};
Self { Self {
model: ShellModel::default(), model: ShellModel::with_external_output(LIVE_PANE)
.expect("the live comparison pane is a terminal"),
timing: RenderTiming::default(), timing: RenderTiming::default(),
runtime,
runtime_status,
runtime_lines: PtyLineFramer::default(),
last_runtime_sequence: 0,
root_focus, root_focus,
pane_focus, pane_focus,
} }
@@ -134,6 +251,87 @@ impl LumbridgeShell {
self.model.dispatch(action) self.model.dispatch(action)
} }
fn drain_runtime_events(&mut self) -> bool {
if self.runtime_status.is_terminal() {
return false;
}
let mut changed = false;
for _ in 0..RUNTIME_DRAIN_LIMIT {
let event = match self.runtime.as_ref().map(RuntimeActor::try_recv) {
Some(Ok(Some(event))) => event,
Some(Ok(None)) | None => break,
Some(Err(RuntimeActorError::Disconnected)) => {
self.runtime_status =
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
changed = true;
break;
}
Some(Err(error)) => {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
changed = true;
break;
}
};
match event {
RuntimeEvent::Started {
session_id,
process_id,
} => {
self.runtime_status = LiveRuntimeStatus::Running {
session_id: session_id.get(),
process_id,
};
changed = true;
}
RuntimeEvent::Output {
sequence, bytes, ..
} => {
if sequence <= self.last_runtime_sequence {
self.runtime_status = LiveRuntimeStatus::Fault(format!(
"non-monotonic PTY output sequence {sequence}"
));
changed = true;
break;
}
self.last_runtime_sequence = sequence;
let lines = self.runtime_lines.push(&bytes);
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
changed = true;
}
}
RuntimeEvent::InputClosed { .. } => {}
RuntimeEvent::Exited { status, .. } => {
let lines = self.runtime_lines.finish();
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
}
self.runtime_status =
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
changed = true;
break;
}
RuntimeEvent::Fault {
operation, message, ..
} => {
self.runtime_status =
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
changed = true;
break;
}
}
}
changed
}
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) { fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::SelectPane(pane)); self.dispatch(ShellAction::SelectPane(pane));
window.focus(&self.pane_focus[pane.index()]); window.focus(&self.pane_focus[pane.index()]);
@@ -210,16 +408,22 @@ impl LumbridgeShell {
fn pane_card( fn pane_card(
pane: &PaneState, pane: &PaneState,
selected: bool, selected: bool,
runtime_status: LiveRuntimeStatus,
focus: FocusHandle, focus: FocusHandle,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::AnyElement { ) -> gpui::AnyElement {
let id = pane.id(); let id = pane.id();
let needs_input = pane.needs_input(); let needs_input = pane.needs_input();
let label = match pane.kind() { let external = pane.output_source() == OutputSource::External;
SurfaceKind::Terminal => pane.status().label(), let label = if external {
_ => pane.fixture().badge, runtime_status.badge()
} else {
match pane.kind() {
SurfaceKind::Terminal => pane.status().label(),
_ => pane.fixture().badge,
}
}; };
let state_color = if needs_input { let state_color = if needs_input || (external && runtime_status.is_fault()) {
ATTENTION ATTENTION
} else if matches!(pane.kind(), SurfaceKind::Terminal) { } else if matches!(pane.kind(), SurfaceKind::Terminal) {
SUCCESS SUCCESS
@@ -273,7 +477,11 @@ impl LumbridgeShell {
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child(format!("{}", id.index() + 1)), .child(format!("{}", id.index() + 1)),
) )
.child(pane.fixture().title), .child(if external {
"Shell · runtime actor"
} else {
pane.fixture().title
}),
) )
.child(div().text_xs().text_color(rgb(state_color)).child(label)), .child(div().text_xs().text_color(rgb(state_color)).child(label)),
) )
@@ -286,7 +494,11 @@ impl LumbridgeShell {
.py_2() .py_2()
.text_xs() .text_xs()
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child(pane.fixture().target) .child(if external {
runtime_status.detail()
} else {
pane.fixture().target.to_owned()
})
.when(selected, |view| view.child("FOCUSED")), .when(selected, |view| view.child("FOCUSED")),
) )
.child( .child(
@@ -478,7 +690,7 @@ impl Render for LumbridgeShell {
.py_1() .py_1()
.text_sm() .text_sm()
.text_color(rgb(SUCCESS)) .text_color(rgb(SUCCESS))
.child("metal · connected"), .child(format!("metal · {}", self.runtime_status.badge())),
) )
.child( .child(
div() div()
@@ -555,7 +767,7 @@ impl Render for LumbridgeShell {
.px_3() .px_3()
.text_xs() .text_xs()
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child("6 surfaces · 3 remote · 1 waiting"), .child("1 live PTY · 5 deterministic · 1 waiting"),
); );
let pane_cards = self let pane_cards = self
@@ -566,6 +778,7 @@ impl Render for LumbridgeShell {
Self::pane_card( Self::pane_card(
pane, pane,
self.model.selected_pane() == pane.id(), self.model.selected_pane() == pane.id(),
self.runtime_status.clone(),
self.pane_focus[pane.id().index()].clone(), self.pane_focus[pane.id().index()].clone(),
cx, cx,
) )
@@ -582,10 +795,11 @@ impl Render for LumbridgeShell {
let counters = self.model.counters(); let counters = self.model.counters();
let footer_left = format!( let footer_left = format!(
"rev {} · {} focus moves · {} surface updates · {} lines", "rev {} · {} focus · {} external batches · {} PTY lines · {} total lines",
self.model.revision(), self.model.revision(),
counters.focus_moves, counters.focus_moves,
counters.surface_updates, counters.external_output_batches,
counters.external_lines_appended,
counters.terminal_lines_appended counters.terminal_lines_appended
); );
let timing = self.timing.summary(); let timing = self.timing.summary();
@@ -649,7 +863,7 @@ impl Render for LumbridgeShell {
.mr_4() .mr_4()
.text_xs() .text_xs()
.text_color(rgb(SUCCESS)) .text_color(rgb(SUCCESS))
.child("metal · runtime online"), .child(format!("metal · {}", self.runtime_status.badge())),
) )
.child( .child(
div() div()
@@ -694,6 +908,17 @@ impl Render for LumbridgeShell {
} }
} }
fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
let command = CommandConfig::new("/bin/sh")
.map_err(RuntimeActorError::Start)?
.args(["-c", LIVE_PTY_SCRIPT]);
RuntimeActor::spawn(
command,
PtyOptions::default(),
RuntimeActorOptions::default(),
)
}
fn main() { fn main() {
Application::new().run(|cx: &mut App| { Application::new().run(|cx: &mut App| {
cx.bind_keys([ cx.bind_keys([
@@ -731,3 +956,25 @@ fn main() {
cx.activate(true); cx.activate(true);
}); });
} }
#[cfg(test)]
mod tests {
use super::PtyLineFramer;
#[test]
fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(b"first\r").is_empty());
assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]);
assert!(framer.push(&[0xf0, 0x9f]).is_empty());
assert!(framer.push(&[0x91, 0xa9]).is_empty());
assert_eq!(framer.finish(), ["part👩"]);
}
#[test]
fn replaces_invalid_utf8_only_after_a_complete_line() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(&[0xff]).is_empty());
assert_eq!(framer.push(b"\n"), [""]);
}
}
+104 -3
View File
@@ -81,6 +81,12 @@ pub enum PaneStatus {
Ready, Ready,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputSource {
Deterministic,
External,
}
impl PaneStatus { impl PaneStatus {
#[must_use] #[must_use]
pub const fn label(self) -> &'static str { pub const fn label(self) -> &'static str {
@@ -209,6 +215,7 @@ pub struct PaneState {
status_before_input: Option<PaneStatus>, status_before_input: Option<PaneStatus>,
lines: Vec<String>, lines: Vec<String>,
synthetic_line_sequence: u64, synthetic_line_sequence: u64,
output_source: OutputSource,
} }
impl PaneState { impl PaneState {
@@ -240,6 +247,10 @@ impl PaneState {
pub const fn synthetic_line_sequence(&self) -> u64 { pub const fn synthetic_line_sequence(&self) -> u64 {
self.synthetic_line_sequence self.synthetic_line_sequence
} }
#[must_use]
pub const fn output_source(&self) -> OutputSource {
self.output_source
}
} }
#[derive(Clone, Debug, Default, Eq, PartialEq)] #[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -276,6 +287,7 @@ pub enum ShellAction {
CloseCommandPalette, CloseCommandPalette,
SetCommandPaletteQuery(String), SetCommandPaletteQuery(String),
SyntheticStreamTick, SyntheticStreamTick,
AppendExternalOutput { pane: PaneId, lines: Vec<String> },
} }
/// Counters prove both candidates replayed the same workload. Framework /// Counters prove both candidates replayed the same workload. Framework
@@ -294,6 +306,8 @@ pub struct MeasurementCounters {
/// Total surfaces invalidated by the deterministic six-surface workload. /// Total surfaces invalidated by the deterministic six-surface workload.
pub surface_updates: u64, pub surface_updates: u64,
pub terminal_lines_appended: u64, pub terminal_lines_appended: u64,
pub external_output_batches: u64,
pub external_lines_appended: u64,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -315,6 +329,16 @@ impl fmt::Display for InvalidTerminalLineLimit {
} }
impl std::error::Error for InvalidTerminalLineLimit {} impl std::error::Error for InvalidTerminalLineLimit {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidExternalOutputPane;
impl fmt::Display for InvalidExternalOutputPane {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("external output requires a terminal pane")
}
}
impl std::error::Error for InvalidExternalOutputPane {}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ShellModel { pub struct ShellModel {
panes: [PaneState; GRID_ROWS * GRID_COLUMNS], panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
@@ -350,6 +374,7 @@ impl ShellModel {
.then_some(PaneStatus::Working), .then_some(PaneStatus::Working),
lines, lines,
synthetic_line_sequence: 0, synthetic_line_sequence: 0,
output_source: OutputSource::Deterministic,
} }
}); });
Ok(Self { Ok(Self {
@@ -363,6 +388,22 @@ impl ShellModel {
}) })
} }
/// Creates the comparison model with exactly one actor-driven terminal.
///
/// # Errors
///
/// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal.
pub fn with_external_output(pane: PaneId) -> Result<Self, InvalidExternalOutputPane> {
let mut model = Self::default();
let pane_state = &mut model.panes[pane.index()];
if pane_state.kind() != SurfaceKind::Terminal {
return Err(InvalidExternalOutputPane);
}
pane_state.output_source = OutputSource::External;
pane_state.lines.clear();
Ok(model)
}
#[must_use] #[must_use]
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] { pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
&self.panes &self.panes
@@ -457,7 +498,11 @@ impl ShellModel {
self.synthetic_tick += 1; self.synthetic_tick += 1;
self.counters.synthetic_ticks += 1; self.counters.synthetic_ticks += 1;
for pane in &mut self.panes { for pane in &mut self.panes {
if pane.output_source == OutputSource::External {
continue;
}
pane.synthetic_line_sequence += 1; pane.synthetic_line_sequence += 1;
self.counters.surface_updates += 1;
if pane.kind() != SurfaceKind::Terminal { if pane.kind() != SurfaceKind::Terminal {
continue; continue;
} }
@@ -472,10 +517,25 @@ impl ShellModel {
} }
appended += 1; appended += 1;
} }
self.counters.surface_updates += self.panes.len() as u64;
self.counters.terminal_lines_appended += appended as u64; self.counters.terminal_lines_appended += appended as u64;
changed = true; changed = true;
} }
ShellAction::AppendExternalOutput { pane, lines } => {
let pane = &mut self.panes[pane.index()];
if pane.output_source == OutputSource::External && !lines.is_empty() {
appended = lines.len();
pane.lines.extend(lines);
if pane.lines.len() > self.terminal_line_limit {
pane.lines
.drain(..pane.lines.len() - self.terminal_line_limit);
}
self.counters.surface_updates += 1;
self.counters.terminal_lines_appended += appended as u64;
self.counters.external_output_batches += 1;
self.counters.external_lines_appended += appended as u64;
changed = true;
}
}
} }
if changed { if changed {
self.revision += 1; self.revision += 1;
@@ -511,8 +571,8 @@ pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<P
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction, FocusDirection, GRID_COLUMNS, GRID_ROWS, OutputSource, PANES, PaneId, PaneStatus,
ShellModel, SurfaceKind, focus_neighbor, ShellAction, ShellModel, SurfaceKind, focus_neighbor,
}; };
#[test] #[test]
@@ -699,6 +759,47 @@ mod tests {
assert!(ShellModel::with_terminal_line_limit(0).is_err()); assert!(ShellModel::with_terminal_line_limit(0).is_err());
} }
#[test]
fn one_external_terminal_leaves_five_deterministic_surfaces() {
let mut model = ShellModel::with_external_output(PaneId::CodexRuntime).unwrap();
assert_eq!(
model.pane(PaneId::CodexRuntime).output_source(),
OutputSource::External
);
assert!(model.pane(PaneId::CodexRuntime).lines().is_empty());
let tick = model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(tick.terminal_lines_appended, 2);
assert!(model.pane(PaneId::CodexRuntime).lines().is_empty());
assert_eq!(model.counters().surface_updates, 5);
let output = model.dispatch(ShellAction::AppendExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["runtime ready".into(), "pty line".into()],
});
assert!(output.changed);
assert_eq!(output.terminal_lines_appended, 2);
assert_eq!(
model.pane(PaneId::CodexRuntime).lines(),
["runtime ready", "pty line"]
);
assert_eq!(model.counters().surface_updates, 6);
assert_eq!(model.counters().external_output_batches, 1);
assert_eq!(model.counters().external_lines_appended, 2);
}
#[test]
fn external_output_rejects_nonterminals_and_deterministic_panes() {
assert!(ShellModel::with_external_output(PaneId::Architecture).is_err());
let mut model = ShellModel::default();
let outcome = model.dispatch(ShellAction::AppendExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["must not replace fixture output".into()],
});
assert!(!outcome.changed);
assert_eq!(model.counters().external_output_batches, 0);
}
#[test] #[test]
fn identical_action_replays_produce_identical_models_and_counters() { fn identical_action_replays_produce_identical_models_and_counters() {
let actions = [ let actions = [