Build interactive native workspace vertical slice
CI / rust (push) Successful in 1m43s

This commit is contained in:
2026-08-31 16:15:32 -07:00
parent de89b015bc
commit 27beb69ff8
19 changed files with 10535 additions and 200 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "lumbridge-pty"
description = "Portable local PTY process boundary for Lumbridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
portable-pty = "0.9.0"
thiserror = "2.0"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.28", features = ["process", "signal"] }
[lints]
workspace = true
+716
View File
@@ -0,0 +1,716 @@
//! Portable local PTY process ownership for the Lumbridge runtime.
//!
//! This crate moves unparsed bytes between a child process and its PTY. Terminal
//! emulation, scrollback, persistence, remote sessions, and secret injection are
//! intentionally separate concerns.
#![forbid(unsafe_code)]
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use thiserror::Error;
const OUTPUT_CHUNK_BYTES: usize = 8 * 1024;
const DEFAULT_OUTPUT_QUEUE_CHUNKS: usize = 64;
const TERMINATION_GRACE: Duration = Duration::from_millis(250);
const TERMINATION_POLL: Duration = Duration::from_millis(10);
/// A validated terminal cell and pixel size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalSize {
rows: u16,
columns: u16,
pixel_width: u16,
pixel_height: u16,
}
impl TerminalSize {
/// Creates a cell-only size. Rows and columns must both be non-zero.
///
/// # Errors
///
/// Returns [`PtyError::InvalidSize`] when either cell dimension is zero.
pub fn new(rows: u16, columns: u16) -> Result<Self, PtyError> {
Self::with_pixels(rows, columns, 0, 0)
}
/// Creates a size that also reports the viewport's pixel dimensions.
///
/// # Errors
///
/// Returns [`PtyError::InvalidSize`] when either cell dimension is zero.
pub fn with_pixels(
rows: u16,
columns: u16,
pixel_width: u16,
pixel_height: u16,
) -> Result<Self, PtyError> {
if rows == 0 || columns == 0 {
return Err(PtyError::InvalidSize);
}
Ok(Self {
rows,
columns,
pixel_width,
pixel_height,
})
}
#[must_use]
pub const fn rows(self) -> u16 {
self.rows
}
#[must_use]
pub const fn columns(self) -> u16 {
self.columns
}
}
impl Default for TerminalSize {
fn default() -> Self {
Self {
rows: 24,
columns: 80,
pixel_width: 0,
pixel_height: 0,
}
}
}
impl From<TerminalSize> for PtySize {
fn from(size: TerminalSize) -> Self {
Self {
rows: size.rows,
cols: size.columns,
pixel_width: size.pixel_width,
pixel_height: size.pixel_height,
}
}
}
/// Credential-free process launch configuration.
///
/// The configuration deliberately has no arbitrary environment-variable API.
/// A child receives only a small fixed allowlist from the Lumbridge process,
/// plus terminal capability variables. Future secret injection belongs behind
/// the runtime's secret-store boundary and must not be represented here.
pub struct CommandConfig {
program: OsString,
arguments: Vec<OsString>,
working_directory: Option<PathBuf>,
}
impl CommandConfig {
/// Creates a launch configuration for a non-empty executable name or path.
///
/// # Errors
///
/// Returns [`PtyError::EmptyProgram`] for an empty executable name.
pub fn new(program: impl Into<OsString>) -> Result<Self, PtyError> {
let program = program.into();
if program.is_empty() {
return Err(PtyError::EmptyProgram);
}
Ok(Self {
program,
arguments: Vec::new(),
working_directory: None,
})
}
#[must_use]
pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(argument.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.arguments.extend(arguments.into_iter().map(Into::into));
self
}
#[must_use]
pub fn working_directory(mut self, path: impl Into<PathBuf>) -> Self {
self.working_directory = Some(path.into());
self
}
fn into_builder(self) -> CommandBuilder {
let mut command = CommandBuilder::new(self.program);
command.args(self.arguments);
command.env_clear();
for name in inherited_environment_allowlist() {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
if let Some(directory) = self.working_directory {
command.cwd(directory);
}
command
}
}
fn inherited_environment_allowlist() -> &'static [&'static str] {
&[
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
]
}
/// Spawn-time settings for a local PTY.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PtyOptions {
pub size: TerminalSize,
/// Capacity in fixed-size output chunks. The reader blocks when full,
/// propagating backpressure to the kernel PTY rather than growing memory.
pub output_queue_chunks: NonZeroUsize,
}
impl PtyOptions {
#[must_use]
pub const fn new(size: TerminalSize) -> Self {
let output_queue_chunks = match NonZeroUsize::new(DEFAULT_OUTPUT_QUEUE_CHUNKS) {
Some(capacity) => capacity,
None => NonZeroUsize::MIN,
};
Self {
size,
output_queue_chunks,
}
}
#[must_use]
pub const fn with_output_queue_chunks(mut self, capacity: NonZeroUsize) -> Self {
self.output_queue_chunks = capacity;
self
}
}
impl Default for PtyOptions {
fn default() -> Self {
Self::new(TerminalSize::default())
}
}
/// A single item delivered by the bounded PTY output queue.
#[derive(Debug, Eq, PartialEq)]
pub enum OutputEvent {
/// Unparsed bytes read from the PTY. A chunk is never larger than 8 KiB.
Data(Vec<u8>),
/// The PTY slave closed normally.
Eof,
/// The reader stopped because the operating-system read failed.
ReadFailed(PtyReadError),
}
/// A redacted, cloneable description of a PTY reader failure.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PtyReadError {
pub kind: io::ErrorKind,
pub message: String,
}
/// A completed child-process status.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExitStatus {
pub code: u32,
pub signal: Option<String>,
}
impl ExitStatus {
#[must_use]
pub const fn success(&self) -> bool {
self.code == 0 && self.signal.is_none()
}
}
impl From<portable_pty::ExitStatus> for ExitStatus {
fn from(status: portable_pty::ExitStatus) -> Self {
Self {
code: status.exit_code(),
signal: status.signal().map(ToOwned::to_owned),
}
}
}
#[derive(Debug, Error)]
pub enum PtyError {
#[error("PTY program cannot be empty")]
EmptyProgram,
#[error("PTY rows and columns must both be non-zero")]
InvalidSize,
#[error("failed to open the local PTY: {0}")]
Open(String),
#[error("failed to clone the local PTY reader: {0}")]
CloneReader(String),
#[error("failed to take the local PTY writer: {0}")]
TakeWriter(String),
#[error("failed to spawn the local PTY child: {0}")]
Spawn(String),
#[error("the PTY session is closed")]
Closed,
#[error("PTY I/O failed")]
Io(#[source] io::Error),
#[error("PTY child did not exit within {0:?}")]
ExitTimeout(Duration),
#[error("PTY output was not available before the timeout")]
OutputTimeout,
#[error("PTY output reader disconnected without an EOF event")]
OutputDisconnected,
}
/// Owns one local PTY, its child process, and its bounded output queue.
///
/// A session is intentionally single-owner. The future runtime actor performs
/// input ordering and forwards output events to terminal emulation.
pub struct PtySession {
master: Option<Box<dyn MasterPty + Send>>,
writer: Option<Box<dyn Write + Send>>,
child: Box<dyn Child + Send + Sync>,
process_id: Option<u32>,
output: Option<Receiver<OutputEvent>>,
reader_thread: Option<JoinHandle<()>>,
exit_status: Option<ExitStatus>,
}
impl PtySession {
/// Opens a native PTY and spawns the configured child inside it.
///
/// # Errors
///
/// Returns an error when the PTY, reader, writer, child, or reader thread
/// cannot be created.
pub fn spawn(config: CommandConfig, options: PtyOptions) -> Result<Self, PtyError> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(options.size.into())
.map_err(|error| PtyError::Open(error.to_string()))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|error| PtyError::CloneReader(error.to_string()))?;
let writer = pair
.master
.take_writer()
.map_err(|error| PtyError::TakeWriter(error.to_string()))?;
let (sender, output) = mpsc::sync_channel(options.output_queue_chunks.get());
let reader_thread = thread::Builder::new()
.name("lumbridge-pty-reader".to_owned())
.spawn(move || read_output(reader, &sender))
.map_err(PtyError::Io)?;
let child = pair
.slave
.spawn_command(config.into_builder())
.map_err(|error| PtyError::Spawn(error.to_string()))?;
let process_id = child.process_id();
drop(pair.slave);
Ok(Self {
master: Some(pair.master),
writer: Some(writer),
child,
process_id,
output: Some(output),
reader_thread: Some(reader_thread),
exit_status: None,
})
}
#[must_use]
pub const fn process_id(&self) -> Option<u32> {
self.process_id
}
/// Writes ordered raw input bytes to the PTY and flushes the writer.
///
/// # Errors
///
/// Returns [`PtyError::Closed`] after input has closed, or [`PtyError::Io`]
/// when the operating-system write fails.
pub fn write_all(&mut self, bytes: &[u8]) -> Result<(), PtyError> {
let writer = self.writer.as_mut().ok_or(PtyError::Closed)?;
writer.write_all(bytes).map_err(PtyError::Io)?;
writer.flush().map_err(PtyError::Io)
}
/// Applies a validated terminal size to the PTY.
///
/// # Errors
///
/// Returns [`PtyError::Closed`] after session cleanup, or [`PtyError::Io`]
/// when the platform resize operation fails.
pub fn resize(&self, size: TerminalSize) -> Result<(), PtyError> {
self.master
.as_ref()
.ok_or(PtyError::Closed)?
.resize(size.into())
.map_err(|error| PtyError::Io(io::Error::other(error.to_string())))
}
/// Receives the next bounded output event before `timeout` elapses.
///
/// # Errors
///
/// Returns a closed, timeout, or unexpected-disconnection error when no
/// event can be delivered.
pub fn recv_output_timeout(&self, timeout: Duration) -> Result<OutputEvent, PtyError> {
let output = self.output.as_ref().ok_or(PtyError::Closed)?;
match output.recv_timeout(timeout) {
Ok(event) => Ok(event),
Err(RecvTimeoutError::Timeout) => Err(PtyError::OutputTimeout),
Err(RecvTimeoutError::Disconnected) => Err(PtyError::OutputDisconnected),
}
}
/// Polls the child without blocking and caches a completed status.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when the platform child poll fails.
pub fn try_wait(&mut self) -> Result<Option<ExitStatus>, PtyError> {
if let Some(status) = &self.exit_status {
return Ok(Some(status.clone()));
}
let status: Option<ExitStatus> =
self.child.try_wait().map_err(PtyError::Io)?.map(Into::into);
if let Some(status) = &status {
self.exit_status = Some(status.clone());
}
Ok(status)
}
/// Blocks until the session leader exits and caches its status.
///
/// Callers should continue consuming output from another runtime task when
/// a child may produce more than the bounded queue can hold.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when the platform wait fails.
pub fn wait(&mut self) -> Result<ExitStatus, PtyError> {
if let Some(status) = &self.exit_status {
return Ok(status.clone());
}
let status = ExitStatus::from(self.child.wait().map_err(PtyError::Io)?);
self.exit_status = Some(status.clone());
Ok(status)
}
/// Polls until the child exits or the supplied timeout expires.
///
/// # Errors
///
/// Returns [`PtyError::ExitTimeout`] on expiry or [`PtyError::Io`] when a
/// platform child poll fails.
pub fn wait_timeout(&mut self, timeout: Duration) -> Result<ExitStatus, PtyError> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = self.try_wait()? {
return Ok(status);
}
if Instant::now() >= deadline {
return Err(PtyError::ExitTimeout(timeout));
}
thread::sleep(TERMINATION_POLL.min(deadline.saturating_duration_since(Instant::now())));
}
}
/// Terminates the PTY's process tree and waits for the session leader.
///
/// Unix children run in the session/process group created by
/// `portable-pty`; the whole group is signalled so a shell's foreground
/// process cannot be orphaned. Other platforms use the portable child
/// killer.
///
/// # Errors
///
/// Returns [`PtyError::Io`] when signalling or waiting for the child fails.
pub fn terminate(&mut self) -> Result<ExitStatus, PtyError> {
self.close_input();
terminate_process_tree(self.process_id, self.child.as_mut())?;
self.wait()
}
/// Closes the input half without terminating the child.
pub fn close_input(&mut self) {
self.writer.take();
}
fn disconnect_output(&mut self) {
self.output.take();
}
fn join_finished_reader(&mut self) {
if self
.reader_thread
.as_ref()
.is_some_and(JoinHandle::is_finished)
&& let Some(reader_thread) = self.reader_thread.take()
{
let _ = reader_thread.join();
}
}
}
impl Drop for PtySession {
fn drop(&mut self) {
// Disconnect first so a reader blocked by queue backpressure can exit.
self.disconnect_output();
self.close_input();
if terminate_process_tree(self.process_id, self.child.as_mut()).is_ok() {
let _ = self.child.wait();
} else {
// Destructors must not hang indefinitely when platform signalling
// itself failed. The child handle is still dropped below.
let _ = self.child.try_wait();
}
self.master.take();
self.join_finished_reader();
}
}
fn read_output(mut reader: Box<dyn Read + Send>, sender: &SyncSender<OutputEvent>) {
let mut buffer = vec![0_u8; OUTPUT_CHUNK_BYTES];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
let _ = sender.send(OutputEvent::Eof);
return;
}
Ok(length) => {
if sender
.send(OutputEvent::Data(buffer[..length].to_vec()))
.is_err()
{
return;
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => {
let _ = sender.send(OutputEvent::ReadFailed(PtyReadError {
kind: error.kind(),
message: error.to_string(),
}));
return;
}
}
}
}
#[cfg(unix)]
fn terminate_process_tree(
process_id: Option<u32>,
child: &mut (dyn Child + Send + Sync),
) -> Result<(), PtyError> {
use nix::errno::Errno;
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let Some(process_id) = process_id.and_then(|id| i32::try_from(id).ok()) else {
child.kill().map_err(PtyError::Io)?;
return Ok(());
};
let group = Pid::from_raw(process_id);
if let Err(error) = killpg(group, Signal::SIGHUP)
&& error != Errno::ESRCH
{
return Err(PtyError::Io(io::Error::from_raw_os_error(error as i32)));
}
let deadline = Instant::now() + TERMINATION_GRACE;
while Instant::now() < deadline {
if child.try_wait().map_err(PtyError::Io)?.is_some() {
break;
}
thread::sleep(TERMINATION_POLL);
}
// Always address the group. The leader may have exited while a foreground
// descendant that ignored SIGHUP remains attached to the terminal.
if let Err(error) = killpg(group, Signal::SIGKILL)
&& error != Errno::ESRCH
{
return Err(PtyError::Io(io::Error::from_raw_os_error(error as i32)));
}
Ok(())
}
#[cfg(not(unix))]
fn terminate_process_tree(
_process_id: Option<u32>,
child: &mut (dyn Child + Send + Sync),
) -> Result<(), PtyError> {
child.kill().map_err(PtyError::Io)
}
#[cfg(test)]
mod tests {
use super::{
CommandConfig, ExitStatus, OutputEvent, PtyError, PtyOptions, PtySession, TerminalSize,
};
use std::num::NonZeroUsize;
use std::time::{Duration, Instant};
const TEST_TIMEOUT: Duration = Duration::from_secs(3);
fn shell(script: &str) -> CommandConfig {
CommandConfig::new("/bin/sh")
.expect("/bin/sh is a valid program")
.args(["-c", script])
}
fn collect_until_eof(session: &PtySession) -> Vec<u8> {
let deadline = Instant::now() + TEST_TIMEOUT;
let mut output = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "timed out collecting PTY output");
match session.recv_output_timeout(remaining) {
Ok(OutputEvent::Data(bytes)) => {
assert!(bytes.len() <= super::OUTPUT_CHUNK_BYTES);
output.extend(bytes);
}
Ok(OutputEvent::Eof) => return output,
Ok(OutputEvent::ReadFailed(error)) => panic!("PTY read failed: {error:?}"),
Err(error) => panic!("PTY output failed: {error}"),
}
}
}
#[test]
fn captures_raw_output_and_nonzero_exit() {
let mut session = PtySession::spawn(
shell("printf 'lumbridge-ready\\n'; exit 7"),
PtyOptions::default(),
)
.expect("spawn synthetic shell");
let output = collect_until_eof(&session);
let status = session.wait_timeout(TEST_TIMEOUT).expect("shell exits");
assert!(String::from_utf8_lossy(&output).contains("lumbridge-ready"));
assert_eq!(
status,
ExitStatus {
code: 7,
signal: None
}
);
}
#[test]
fn writes_input_and_reports_resize_to_child() {
let initial = TerminalSize::new(24, 80).expect("valid initial size");
let resized = TerminalSize::new(41, 101).expect("valid resized size");
let mut session = PtySession::spawn(
shell("IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\""),
PtyOptions::new(initial),
)
.expect("spawn synthetic shell");
session.resize(resized).expect("resize PTY");
session
.write_all(b"hello-lumbridge\n")
.expect("write PTY input");
let output = String::from_utf8_lossy(&collect_until_eof(&session)).replace('\r', "");
let status = session.wait_timeout(TEST_TIMEOUT).expect("shell exits");
assert!(output.contains("41 101"), "unexpected output: {output:?}");
assert!(
output.contains("got:hello-lumbridge"),
"unexpected output: {output:?}"
);
assert!(status.success());
}
#[test]
fn bounded_queue_preserves_large_output_when_consumed() {
let options = PtyOptions::default()
.with_output_queue_chunks(NonZeroUsize::new(1).expect("non-zero capacity"));
let mut session = PtySession::spawn(
shell("i=0; while [ $i -lt 2048 ]; do printf '0123456789abcdef'; i=$((i+1)); done"),
options,
)
.expect("spawn synthetic producer");
let output = collect_until_eof(&session);
let status = session.wait_timeout(TEST_TIMEOUT).expect("producer exits");
assert_eq!(output.len(), 32 * 1024);
assert!(status.success());
}
#[test]
fn terminate_stops_a_hung_child_with_a_timeout_bound() {
let mut session =
PtySession::spawn(shell("trap '' HUP; exec sleep 30"), PtyOptions::default())
.expect("spawn synthetic sleeper");
let started = Instant::now();
let status = session.terminate().expect("terminate process tree");
assert!(!status.success());
assert!(started.elapsed() < TEST_TIMEOUT);
}
#[test]
fn rejects_invalid_dimensions_and_empty_program() {
assert!(matches!(
TerminalSize::new(0, 80),
Err(PtyError::InvalidSize)
));
assert!(matches!(
CommandConfig::new(""),
Err(PtyError::EmptyProgram)
));
}
#[test]
fn child_environment_is_minimal_and_excludes_provider_credentials() {
let command = shell("exit 0").into_builder();
assert_eq!(
command.get_env("TERM"),
Some(std::ffi::OsStr::new("xterm-256color"))
);
for forbidden in [
"ANTHROPIC_API_KEY",
"CEREBRAS_API_KEY",
"GEMINI_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
] {
assert_eq!(command.get_env(forbidden), None, "inherited {forbidden}");
}
}
}