use std::io::{self, Read, Write};
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
use portable_pty::{
native_pty_system, ChildKiller, CommandBuilder, ExitStatus, MasterPty, PtySize,
};
use tokio::sync::mpsc;
const READ_CHUNK: usize = 8192;
const OUTPUT_CHANNEL_DEPTH: usize = 512;
const WRITE_CHANNEL_DEPTH: usize = 1024;
#[derive(Debug, thiserror::Error)]
pub enum PtyError {
#[error("opening pty: {0}")]
OpenPty(#[source] io::Error),
#[error("spawning shell: {0}")]
Spawn(#[source] io::Error),
#[error("starting pty reader: {0}")]
Reader(#[from] io::Error),
#[error("resizing pty: {0}")]
Resize(#[source] io::Error),
}
pub struct Pty {
master: Box<dyn MasterPty + Send>,
writer_tx: SyncSender<Vec<u8>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
killer: Box<dyn ChildKiller + Send + Sync>,
reader_handle: Option<std::thread::JoinHandle<()>>,
writer_handle: Option<std::thread::JoinHandle<()>>,
}
impl Pty {
pub fn spawn(
rows: u16,
cols: u16,
shell: Option<&str>,
term: &str,
) -> Result<(Self, mpsc::Receiver<Vec<u8>>), PtyError> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| PtyError::OpenPty(io::Error::other(e)))?;
let mut cmd = match shell {
Some(prog) => CommandBuilder::new(prog),
None => CommandBuilder::new_default_prog(),
};
cmd.env("TERM", term);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| PtyError::Spawn(io::Error::other(e)))?;
let killer = child.clone_killer();
drop(pair.slave);
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| PtyError::Reader(io::Error::other(e)))?;
let mut writer = pair
.master
.take_writer()
.map_err(|e| PtyError::Reader(io::Error::other(e)))?;
let (tx, rx) = mpsc::channel::<Vec<u8>>(OUTPUT_CHANNEL_DEPTH);
let reader_handle = std::thread::Builder::new()
.name("koh-pty-reader".into())
.spawn(move || {
let mut buf = [0u8; READ_CHUNK];
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
let Some(chunk) = buf.get(..n) else { break };
if tx.blocking_send(chunk.to_vec()).is_err() {
break; }
}
Err(e) => {
tracing::debug!(error = %e, "pty reader stopping");
break;
}
}
}
})?;
let (writer_tx, writer_rx) = sync_channel::<Vec<u8>>(WRITE_CHANNEL_DEPTH);
let writer_handle = std::thread::Builder::new()
.name("koh-pty-writer".into())
.spawn(move || {
while let Ok(chunk) = writer_rx.recv() {
if writer
.write_all(&chunk)
.and_then(|()| writer.flush())
.is_err()
{
break; }
}
})?;
Ok((
Self {
master: pair.master,
writer_tx,
child,
killer,
reader_handle: Some(reader_handle),
writer_handle: Some(writer_handle),
},
rx,
))
}
pub fn shutdown(mut self) {
if let Err(e) = self.killer.kill() {
tracing::warn!(error = %e, "pty kill on shutdown failed; reader join may stall");
}
let reader = self.reader_handle.take();
let writer = self.writer_handle.take();
drop(self);
if let Some(h) = writer {
let _ = h.join();
}
if let Some(h) = reader {
let _ = h.join();
}
}
pub fn write_input(&self, data: &[u8]) -> io::Result<()> {
match self.writer_tx.try_send(data.to_vec()) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => Err(io::Error::new(
io::ErrorKind::WouldBlock,
"pty writer queue full (child not draining its input)",
)),
Err(TrySendError::Disconnected(_)) => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
}
}
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| PtyError::Resize(io::Error::other(e)))
}
pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
self.child.try_wait()
}
pub fn wait(&mut self) -> std::io::Result<ExitStatus> {
self.child.wait()
}
pub fn killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
self.killer.clone_killer()
}
pub fn kill(&mut self) -> std::io::Result<()> {
self.killer.kill()
}
pub fn process_id(&self) -> Option<u32> {
self.child.process_id()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
#[allow(
clippy::items_after_statements,
reason = "`_assert_typed` is a deliberate compile-time signature assertion kept beside the runtime checks it documents"
)]
fn pty_error_variants_are_constructible_and_reachable() {
let mk = || io::Error::other("boom");
for e in [
PtyError::OpenPty(mk()),
PtyError::Spawn(mk()),
PtyError::Reader(mk()),
PtyError::Resize(mk()),
] {
assert!(!e.to_string().is_empty(), "variant must Display");
}
let from_io: PtyError = mk().into();
assert!(matches!(from_io, PtyError::Reader(_)));
let absorbed: anyhow::Error = PtyError::OpenPty(mk()).into();
assert!(absorbed.to_string().contains("opening pty"));
fn _assert_typed(r: Result<(), PtyError>) -> Result<(), PtyError> {
r
}
}
#[tokio::test]
#[allow(
clippy::match_wild_err_arm,
reason = "a timeout in this test IS the test failing; panicking on the `Err(_)` deadline arm is the intended assertion"
)]
async fn spawns_and_streams_output() {
let (mut pty, mut rx) =
Pty::spawn(24, 80, Some("echo"), "xterm-256color").expect("spawn echo");
let mut collected = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
Ok(None) => break, Err(_) => panic!("timed out waiting for pty output"),
}
}
assert!(
collected.contains(&b'\n'),
"expected a newline from echo, got {collected:?}"
);
let status = pty.wait().expect("wait");
assert!(status.success() || status.exit_code() == 0);
}
#[tokio::test]
#[allow(
clippy::match_same_arms,
reason = "channel-close (`Ok(None)`) and deadline (`Err(_)`) are conceptually distinct outcomes kept as separate arms for readability, even though both set `found = false`"
)]
async fn interactive_shell_echoes_input() {
let (mut pty, mut rx) = Pty::spawn(24, 80, None, "xterm-256color").expect("spawn shell");
tokio::time::sleep(Duration::from_millis(300)).await;
pty.write_input(b"printf KOH_MARKER_OK\n").expect("write");
let mut collected = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let found = loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(chunk)) => {
collected.extend_from_slice(&chunk);
if String::from_utf8_lossy(&collected).contains("KOH_MARKER_OK") {
break true;
}
}
Ok(None) => break false,
Err(_) => break false,
}
};
let _ = pty.resize(40, 120);
let _ = pty.kill();
assert!(
found,
"did not observe the marker in shell output: {}",
String::from_utf8_lossy(&collected)
);
}
#[tokio::test]
#[allow(
clippy::match_same_arms,
reason = "channel-close (`Ok(None)`) and deadline (`Err(_)`) are conceptually distinct outcomes kept as separate arms for readability, even though both set `in_order = false`"
)]
async fn write_input_takes_shared_ref_and_preserves_order() {
let (pty, mut rx) = Pty::spawn(24, 80, None, "xterm-256color").expect("spawn shell");
tokio::time::sleep(Duration::from_millis(300)).await;
pty.write_input(b"printf ORDER_").expect("first enqueue");
pty.write_input(b"AB_CD\n").expect("second enqueue");
let mut collected = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let in_order = loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(chunk)) => {
collected.extend_from_slice(&chunk);
if String::from_utf8_lossy(&collected).contains("ORDER_AB_CD") {
break true;
}
}
Ok(None) => break false,
Err(_) => break false,
}
};
drop(pty);
assert!(
in_order,
"FIFO ordering of two enqueues should yield ORDER_AB_CD; got: {}",
String::from_utf8_lossy(&collected)
);
}
#[tokio::test]
#[allow(
clippy::needless_continue,
clippy::match_wild_err_arm,
reason = "the explicit `continue` documents the drain-and-keep-reading intent; the `Err(_)` deadline arm panics because a timeout here IS the test failing"
)]
async fn dropping_pty_eofs_child_and_stops_writer() {
let (pty, mut rx) = Pty::spawn(24, 80, Some("cat"), "xterm-256color").expect("spawn cat");
tokio::time::sleep(Duration::from_millis(200)).await;
drop(pty);
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(_)) => continue, Ok(None) => break, Err(_) => panic!("dropping Pty did not EOF the child within 5s (writer stuck?)"),
}
}
}
#[tokio::test]
async fn shutdown_joins_both_io_threads_without_deadlock() {
let (pty, mut rx) = Pty::spawn(24, 80, Some("sh"), "xterm-256color").expect("spawn shell");
let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::timeout(
Duration::from_secs(20),
tokio::task::spawn_blocking(move || pty.shutdown()),
)
.await
.expect("shutdown must not deadlock (both threads must unblock and join)")
.expect("shutdown task panicked");
let _ = drain.await;
}
}