This commit is contained in:
Generated
+8
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+99
-6
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+259
-12
@@ -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;
|
||||||
|
let label = if external {
|
||||||
|
runtime_status.badge()
|
||||||
|
} else {
|
||||||
|
match pane.kind() {
|
||||||
SurfaceKind::Terminal => pane.status().label(),
|
SurfaceKind::Terminal => pane.status().label(),
|
||||||
_ => pane.fixture().badge,
|
_ => 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"), ["�"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user