use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, State};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term};
use alacritty_terminal::tty;
use anyhow::{Context, Result};
use tokio::sync::mpsc;
pub type TabId = u64;
const CELL_WIDTH_PX: u16 = 8;
const CELL_HEIGHT_PX: u16 = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridSize {
pub cols: u16,
pub lines: u16,
}
impl GridSize {
pub fn window_size(self) -> WindowSize {
WindowSize {
num_lines: self.lines,
num_cols: self.cols,
cell_width: CELL_WIDTH_PX,
cell_height: CELL_HEIGHT_PX,
}
}
}
impl Dimensions for GridSize {
fn total_lines(&self) -> usize {
usize::from(self.lines)
}
fn screen_lines(&self) -> usize {
usize::from(self.lines)
}
fn columns(&self) -> usize {
usize::from(self.cols)
}
}
#[derive(Clone)]
pub struct UiEventProxy {
tab: TabId,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
}
impl EventListener for UiEventProxy {
fn send_event(&self, event: TermEvent) {
let _ = self.tx.send((self.tab, event));
}
}
#[derive(Debug, Clone)]
pub struct SpawnRequest {
pub tab: TabId,
pub program: Option<(String, Vec<String>)>,
pub cwd: PathBuf,
pub size: GridSize,
pub extra_env: Vec<(String, String)>,
}
pub struct PtyHandle {
pub term: Arc<FairMutex<Term<UiEventProxy>>>,
pub sender: EventLoopSender,
thread: Option<JoinHandle<(EventLoop<tty::Pty, UiEventProxy>, State)>>,
pub child_pid: i32,
}
impl PtyHandle {
pub fn take_thread(
&mut self,
) -> Option<JoinHandle<(EventLoop<tty::Pty, UiEventProxy>, State)>> {
self.thread.take()
}
}
pub const REAP_GRACE: Duration = Duration::from_millis(250);
#[cfg(unix)]
pub fn reap_child_group(pid: i32, grace: Duration) {
use nix::sys::signal::{killpg, Signal};
use nix::unistd::Pid;
if pid <= 1 {
return;
}
let group = Pid::from_raw(pid);
let _ = killpg(group, Signal::SIGHUP);
std::thread::sleep(grace);
let _ = killpg(group, Signal::SIGKILL);
}
#[cfg(not(unix))]
pub fn reap_child_group(_pid: i32, _grace: Duration) {}
pub fn child_env(extra: impl IntoIterator<Item = (String, String)>) -> HashMap<String, String> {
let mut env = HashMap::new();
env.insert("TERM".to_string(), "xterm-256color".to_string());
env.insert("COLORTERM".to_string(), "truecolor".to_string());
env.extend(extra);
env
}
pub fn spawn(
request: &SpawnRequest,
tx: mpsc::UnboundedSender<(TabId, TermEvent)>,
) -> Result<PtyHandle> {
let proxy = UiEventProxy {
tab: request.tab,
tx,
};
#[allow(clippy::needless_update)]
let options = tty::Options {
shell: request
.program
.as_ref()
.map(|(program, args)| tty::Shell::new(program.clone(), args.clone())),
working_directory: Some(request.cwd.clone()),
drain_on_exit: true,
env: child_env(request.extra_env.iter().cloned()),
..Default::default()
};
let pty = tty::new(&options, request.size.window_size(), request.tab)
.with_context(|| format!("failed to spawn a terminal in {}", request.cwd.display()))?;
let child_pid = i32::try_from(pty.child().id()).unwrap_or(0);
let term = Term::new(Config::default(), &request.size, proxy.clone());
let term = Arc::new(FairMutex::new(term));
let event_loop = EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, false)
.context("failed to start the PTY reader")?;
let sender = event_loop.channel();
let thread = event_loop.spawn();
Ok(PtyHandle {
term,
sender,
thread: Some(thread),
child_pid,
})
}
#[cfg(all(test, unix))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod reap_tests {
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::process::{Command, Stdio};
use std::time::Instant;
use super::*;
fn group_leader(script: &str) -> std::process::Child {
let child = Command::new("/bin/sh")
.args(["-c", script])
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
std::thread::sleep(Duration::from_millis(100));
child
}
fn sigkill() -> i32 {
nix::sys::signal::Signal::SIGKILL as i32
}
fn sighup() -> i32 {
nix::sys::signal::Signal::SIGHUP as i32
}
#[test]
fn a_child_ignoring_sighup_is_escalated_and_reaped() {
let mut child = group_leader("trap '' HUP; sleep 60");
let pid = i32::try_from(child.id()).unwrap();
let started = Instant::now();
reap_child_group(pid, REAP_GRACE);
let status = child.wait().unwrap();
assert_eq!(
status.signal(),
Some(sigkill()),
"a child ignoring SIGHUP must be escalated to SIGKILL"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"reaping took {:?}; it is meant to be bounded by the grace period",
started.elapsed()
);
}
#[test]
fn a_well_behaved_child_dies_of_sighup_and_is_never_escalated() {
let mut child = group_leader("sleep 60");
let pid = i32::try_from(child.id()).unwrap();
reap_child_group(pid, REAP_GRACE);
let status = child.wait().unwrap();
assert_eq!(
status.signal(),
Some(sighup()),
"a child that honours SIGHUP must die of it, not of SIGKILL"
);
}
#[test]
fn a_pty_spawned_child_is_a_signallable_group_leader() {
use nix::sys::signal::killpg;
use nix::unistd::Pid;
let (tx, _rx) = mpsc::unbounded_channel();
let request = SpawnRequest {
tab: 99,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), "sleep 30".to_string()],
)),
cwd: std::env::temp_dir(),
size: GridSize { cols: 40, lines: 6 },
extra_env: Vec::new(),
};
let handle = spawn(&request, tx).unwrap();
let pid = handle.child_pid;
assert!(pid > 1, "spawn must capture a real pid, got {pid}");
std::thread::sleep(Duration::from_millis(150));
assert!(
killpg(Pid::from_raw(pid), None).is_ok(),
"the captured pid is not a process-group leader, so a group-wide \
signal would miss the child entirely"
);
reap_child_group(pid, Duration::from_millis(50));
assert_eq!(
killpg(Pid::from_raw(pid), None),
Err(nix::errno::Errno::ESRCH),
"the group survived reaping"
);
}
#[test]
fn pid_zero_init_and_negatives_are_never_signalled() {
for pid in [0, 1, -1, -5] {
reap_child_group(pid, Duration::from_millis(1));
}
std::thread::sleep(Duration::from_millis(30));
}
}
#[cfg(all(test, unix))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::borrow::Cow;
use std::time::Duration;
use alacritty_terminal::event_loop::Msg;
use alacritty_terminal::term::TermMode;
use super::*;
fn sh(script: &str) -> SpawnRequest {
SpawnRequest {
tab: 1,
program: Some((
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
)),
cwd: std::env::temp_dir(),
size: GridSize {
cols: 60,
lines: 10,
},
extra_env: Vec::new(),
}
}
fn grid_text(handle: &PtyHandle) -> String {
let term = handle.term.lock();
let cols = term.columns();
let mut out = String::new();
let mut line = String::new();
for cell in term.grid().display_iter() {
line.push(cell.c);
if cell.point.column.0 + 1 == cols {
out.push_str(line.trim_end());
out.push('\n');
line.clear();
}
}
out
}
async fn drain_until(
rx: &mut mpsc::UnboundedReceiver<(TabId, TermEvent)>,
handle: &PtyHandle,
timeout: Duration,
mut done: impl FnMut(&TermEvent, &PtyHandle) -> bool,
) -> Vec<TermEvent> {
let deadline = tokio::time::Instant::now() + timeout;
let mut seen = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return seen;
}
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Some((_, event))) => {
if let TermEvent::PtyWrite(reply) = &event {
let _ = handle
.sender
.send(Msg::Input(Cow::Owned(reply.as_bytes().to_vec())));
}
let finished = done(&event, handle);
seen.push(event);
if finished {
return seen;
}
}
Ok(None) | Err(_) => return seen,
}
}
}
fn shutdown(mut handle: PtyHandle) {
let _ = handle.sender.send(Msg::Shutdown);
if let Some(thread) = handle.take_thread() {
let _ = thread.join();
}
}
#[test]
fn child_env_always_sets_term_and_colorterm() {
let env = child_env(std::iter::empty());
assert_eq!(env.get("TERM").map(String::as_str), Some("xterm-256color"));
assert_eq!(env.get("COLORTERM").map(String::as_str), Some("truecolor"));
let env = child_env([("FOO".to_string(), "bar".to_string())]);
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
assert!(env.contains_key("TERM"));
}
#[tokio::test]
async fn echo_round_trip_lands_in_the_grid_and_reports_child_exit() {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = spawn(&sh("echo hello-from-pty"), tx).unwrap();
let events = drain_until(&mut rx, &handle, Duration::from_secs(10), |e, _| {
matches!(e, TermEvent::Exit)
})
.await;
assert!(
events.iter().any(|e| matches!(e, TermEvent::ChildExit(_))),
"expected a ChildExit event"
);
assert!(
grid_text(&handle).contains("hello-from-pty"),
"grid was:\n{}",
grid_text(&handle)
);
shutdown(handle);
}
#[tokio::test]
async fn pty_write_replies_are_echoed_back_so_a_querying_child_does_not_hang() {
let (tx, mut rx) = mpsc::unbounded_channel();
let script = "stty -echo -icanon min 1 time 0 2>/dev/null; \
printf '\\033[6n'; \
c=$(dd bs=1 count=1 2>/dev/null); \
if [ -n \"$c\" ]; then echo GOT-REPLY; else echo NO-REPLY; fi";
let handle = spawn(&sh(script), tx).unwrap();
let events = drain_until(&mut rx, &handle, Duration::from_secs(10), |e, _| {
matches!(e, TermEvent::Exit)
})
.await;
assert!(
events.iter().any(|e| matches!(e, TermEvent::PtyWrite(_))),
"the child's CSI 6n never produced a PtyWrite — the query path itself is broken"
);
let text = grid_text(&handle);
assert!(text.contains("GOT-REPLY"), "grid was:\n{text}");
shutdown(handle);
}
#[tokio::test]
async fn vim_enters_and_leaves_the_alt_screen_with_term_set_explicitly() {
let Some(vim) = which("vim") else {
eprintln!("skipping: no `vim` on PATH");
return;
};
let (tx, mut rx) = mpsc::unbounded_channel();
let request = SpawnRequest {
program: Some((
vim,
vec!["-u".into(), "NONE".into(), "-i".into(), "NONE".into()],
)),
..sh("")
};
let handle = spawn(&request, tx).unwrap();
drain_until(&mut rx, &handle, Duration::from_secs(10), |_, h| {
h.term.lock().mode().contains(TermMode::ALT_SCREEN)
})
.await;
assert!(
handle.term.lock().mode().contains(TermMode::ALT_SCREEN),
"vim never entered the alt screen"
);
handle
.sender
.send(Msg::Input(Cow::Borrowed(b":q!\r")))
.unwrap();
drain_until(&mut rx, &handle, Duration::from_secs(10), |e, _| {
matches!(e, TermEvent::Exit)
})
.await;
assert!(
!handle.term.lock().mode().contains(TermMode::ALT_SCREEN),
"vim left without restoring the primary screen"
);
shutdown(handle);
}
#[tokio::test]
async fn osc52_from_a_child_arrives_as_a_clipboard_store_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = spawn(&sh("printf '\\033]52;c;aGVsbG8=\\a'"), tx).unwrap();
let events = drain_until(&mut rx, &handle, Duration::from_secs(10), |e, _| {
matches!(e, TermEvent::ClipboardStore(..))
})
.await;
match events.last() {
Some(TermEvent::ClipboardStore(_, text)) => assert_eq!(text, "hello"),
Some(_) => panic!("terminating event was not a ClipboardStore"),
None => panic!("no events were observed before the timeout"),
}
shutdown(handle);
}
fn which(program: &str) -> Option<String> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(program))
.find(|candidate| candidate.is_file())
.map(|p| p.to_string_lossy().into_owned())
}
}