use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use crate::emu::{Emulator, Vt100Emulator};
use crate::error::{Error, Result};
use crate::keys::Key;
use crate::screen::Screen;
use crate::wait::{next_backoff, Expired, Monitor, INITIAL_BACKOFF, POLL_CAP};
const DRAIN_GRACE: Duration = Duration::from_millis(500);
static PTY_LIFECYCLE: Mutex<()> = Mutex::new(());
fn pty_lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
PTY_LIFECYCLE.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitStatus {
code: u32,
success: bool,
signal: Option<Box<str>>,
}
impl ExitStatus {
fn from_pty(status: &portable_pty::ExitStatus) -> Self {
Self {
code: status.exit_code(),
success: status.success(),
signal: status.signal().map(Into::into),
}
}
#[must_use]
pub fn success(&self) -> bool {
self.success
}
#[must_use]
pub fn code(&self) -> u32 {
self.code
}
#[must_use]
pub fn signal(&self) -> Option<&str> {
self.signal.as_deref()
}
}
impl fmt::Display for ExitStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.signal {
Some(signal) => write!(f, "killed by signal: {signal} (code {})", self.code),
None => write!(f, "exit code {}", self.code),
}
}
}
struct EmuState {
emu: Box<dyn Emulator>,
last_activity: Instant,
eof: bool,
generation: u64,
snapshot_cache: Option<(u64, Screen)>,
}
impl EmuState {
fn new(emu: Box<dyn Emulator>) -> Self {
Self {
emu,
last_activity: Instant::now(),
eof: false,
generation: 0,
snapshot_cache: None,
}
}
fn touch(&mut self) {
self.last_activity = Instant::now();
self.generation += 1;
}
fn snapshot(&mut self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
let screen = self.emu.snapshot();
self.snapshot_cache = Some((self.generation, screen.clone()));
screen
}
fn peek_snapshot(&self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
self.emu.snapshot()
}
}
#[derive(Debug, Clone)]
pub struct TerminalBuilder {
cols: u16,
rows: u16,
timeout: Duration,
args: Vec<OsString>,
env_clear: bool,
envs: Vec<(OsString, OsString)>,
}
impl Default for TerminalBuilder {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
timeout: Duration::from_secs(5),
args: Vec::new(),
env_clear: false,
envs: Vec::new(),
}
}
}
impl TerminalBuilder {
#[must_use]
pub fn size(mut self, cols: u16, rows: u16) -> Self {
self.cols = cols;
self.rows = rows;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env_clear(mut self) -> Self {
self.env_clear = true;
self
}
pub fn spawn(self, program: impl AsRef<OsStr>) -> Result<Terminal> {
let program = program.as_ref();
let command_desc = std::iter::once(program)
.chain(self.args.iter().map(OsString::as_os_str))
.map(|s| s.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
let lifecycle = pty_lifecycle_guard();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: self.rows,
cols: self.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| Error::Pty(format!("openpty failed: {e}")))?;
let mut cmd = CommandBuilder::new(program);
cmd.args(&self.args);
if self.env_clear {
cmd.env_clear();
}
if !self.envs.iter().any(|(k, _)| k == "TERM") {
cmd.env("TERM", "xterm-256color");
}
for (key, value) in &self.envs {
cmd.env(key, value);
}
let reader = pair
.master
.try_clone_reader()
.map_err(|e| Error::Pty(format!("cloning PTY reader failed: {e}")))?;
let writer = pair
.master
.take_writer()
.map_err(|e| Error::Pty(format!("taking PTY writer failed: {e}")))?;
let shared = Arc::new(Monitor::new(EmuState::new(Box::new(Vt100Emulator::new(
self.rows, self.cols,
)))));
let reader_shared = Arc::clone(&shared);
thread::Builder::new()
.name("termlens-pty-reader".into())
.spawn(move || reader_loop(reader, &reader_shared))
.map_err(Error::Io)?;
let child = pair.slave.spawn_command(cmd).map_err(|e| Error::Spawn {
command: command_desc.clone(),
reason: e.to_string(),
})?;
drop(pair.slave);
drop(lifecycle);
Ok(Terminal {
child,
master: Some(pair.master),
writer: Some(writer),
shared,
default_timeout: self.timeout,
exit_status: None,
command_desc,
})
}
}
fn reader_loop(mut reader: Box<dyn Read + Send>, shared: &Monitor<EmuState>) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => shared.mutate(|state| {
state.emu.process(&buf[..n]);
state.touch();
}),
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
shared.mutate(|state| {
state.eof = true;
state.touch();
});
}
pub struct Terminal {
child: Box<dyn portable_pty::Child + Send + Sync>,
master: Option<Box<dyn portable_pty::MasterPty + Send>>,
writer: Option<Box<dyn Write + Send>>,
shared: Arc<Monitor<EmuState>>,
default_timeout: Duration,
exit_status: Option<ExitStatus>,
command_desc: String,
}
impl Terminal {
#[must_use]
pub fn builder() -> TerminalBuilder {
TerminalBuilder::default()
}
#[must_use]
pub fn screen(&self) -> Screen {
self.shared.lock().snapshot()
}
pub fn send(&mut self, key: Key) {
self.write_or_panic(&key.encode(), &format!("{key:?}"));
}
pub fn send_str(&mut self, s: &str) {
self.write_or_panic(s.as_bytes(), "literal text");
}
fn write_or_panic(&mut self, bytes: &[u8], what: &str) {
let writer = self.writer.as_mut().expect("writer lives until drop");
if let Err(e) = writer.write_all(bytes).and_then(|()| writer.flush()) {
panic!(
"termlens: failed to send {what} to `{}` ({e})\n--- screen ---\n{}",
self.command_desc,
self.screen()
);
}
}
pub fn wait_until(&mut self, mut predicate: impl FnMut(&Screen) -> bool) -> Result<()> {
const WHAT: &str = "the screen predicate to hold";
let deadline = Instant::now() + self.default_timeout;
let mut seen_generation = None;
let outcome = self.shared.wait_until(deadline, |state| {
if seen_generation == Some(state.generation) {
return None;
}
seen_generation = Some(state.generation);
let screen = state.peek_snapshot();
if predicate(&screen) {
return Some(Ok(()));
}
if state.eof {
return Some(Err(Error::Eof {
waiting_for: WHAT.into(),
screen,
}));
}
None
});
match outcome {
Ok(inner) => inner,
Err(Expired) => Err(Error::Timeout {
waiting_for: WHAT.into(),
timeout: self.default_timeout,
screen: self.screen(),
}),
}
}
pub fn wait_idle(&mut self, quiet: Duration) -> Result<()> {
let deadline = Instant::now() + self.default_timeout;
let mut guard = self.shared.lock();
loop {
if guard.eof {
return Ok(());
}
let elapsed = guard.last_activity.elapsed();
if elapsed >= quiet && !guard.emu.mid_sequence() {
return Ok(());
}
let now = Instant::now();
if now >= deadline {
let screen = guard.peek_snapshot();
drop(guard);
return Err(Error::Timeout {
waiting_for: format!("{quiet:?} of output silence"),
timeout: self.default_timeout,
screen,
});
}
let sleep = if elapsed < quiet {
quiet - elapsed
} else {
POLL_CAP
}
.min(deadline - now)
.max(Duration::from_millis(1));
guard = self.shared.wait_timeout(guard, sleep);
}
}
pub fn wait_exit(&mut self) -> Result<ExitStatus> {
if let Some(status) = self.exit_status.clone() {
return Ok(status);
}
let deadline = Instant::now() + self.default_timeout;
let mut backoff = INITIAL_BACKOFF;
loop {
if let Some(status) = self.child.try_wait().map_err(Error::Io)? {
let status = ExitStatus::from_pty(&status);
self.exit_status = Some(status.clone());
let _ = self
.shared
.wait_until(Instant::now() + DRAIN_GRACE, |state| {
state.eof.then_some(())
});
return Ok(status);
}
let now = Instant::now();
if now >= deadline {
return Err(Error::Timeout {
waiting_for: format!("`{}` to exit", self.command_desc),
timeout: self.default_timeout,
screen: self.screen(),
});
}
thread::sleep(backoff.min(deadline - now));
backoff = next_backoff(backoff);
}
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.master
.as_ref()
.expect("master lives until drop")
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| Error::Pty(format!("resize failed: {e}")))?;
self.shared.mutate(|state| {
state.emu.set_size(rows, cols);
state.touch();
});
Ok(())
}
}
impl fmt::Debug for Terminal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Terminal")
.field("command", &self.command_desc)
.field("default_timeout", &self.default_timeout)
.field("exit_status", &self.exit_status)
.finish_non_exhaustive()
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _lifecycle = pty_lifecycle_guard();
if self.exit_status.is_none() {
let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
if !already_exited {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
drop(self.writer.take());
drop(self.master.take());
}
}