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;
fn default_shell() -> String {
resolve_shell(std::env::var_os("SHELL"))
}
fn resolve_shell(shell_env: Option<std::ffi::OsString>) -> String {
if let Some(sh) = shell_env {
if !sh.is_empty() {
return sh.to_string_lossy().into_owned();
}
}
if cfg!(target_os = "android") {
"/system/bin/sh".to_string()
} else {
"/bin/sh".to_string()
}
}
#[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_shell()),
};
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::*;
#[test]
fn resolve_shell_prefers_env_then_platform_default() {
use std::ffi::OsString;
assert_eq!(
resolve_shell(Some(OsString::from("/usr/bin/fish"))),
"/usr/bin/fish"
);
let empty = resolve_shell(Some(OsString::new()));
let unset = resolve_shell(None);
assert_eq!(empty, unset, "empty SHELL falls through like unset");
assert!(
unset.starts_with('/') && !unset.is_empty(),
"an absolute fallback path"
);
if cfg!(target_os = "android") {
assert_eq!(unset, "/system/bin/sh");
} else {
assert_eq!(unset, "/bin/sh");
}
}
#[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
}
}
}