use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use crate::core::{Wake, Waker};
fn io_err(e: impl std::fmt::Display) -> io::Error {
io::Error::other(e.to_string())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Lifecycle {
Active,
Idle,
Ok,
Failed,
}
pub struct Task {
pub id: u64,
pub command: String,
pub cwd: PathBuf,
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn Child + Send + Sync>,
parser: Arc<Mutex<vt100::Parser>>,
last_activity: Arc<Mutex<Instant>>,
handle: Option<JoinHandle<()>>,
pub tagged: bool,
pub exit_code: Option<i32>,
pub started: Instant,
pub finished: Option<Instant>,
term_sent: Option<Instant>,
}
fn signal(waker: &Waker) {
if let Ok(slot) = waker.lock()
&& let Some(tx) = slot.as_ref()
{
let _ = tx.send(Wake::Output);
}
}
impl Task {
pub fn spawn(
id: u64,
command: &str,
cwd: &Path,
rows: u16,
cols: u16,
waker: Waker,
) -> io::Result<Task> {
let pair = native_pty_system()
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(io_err)?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let mut cmd = CommandBuilder::new(shell);
cmd.arg("-c");
cmd.arg(command);
for (k, v) in std::env::vars_os() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
cmd.env("PWD", cwd.as_os_str());
cmd.cwd(cwd);
let child = pair.slave.spawn_command(cmd).map_err(io_err)?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().map_err(io_err)?;
let writer = pair.master.take_writer().map_err(io_err)?;
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
let last_activity = Arc::new(Mutex::new(Instant::now()));
let handle = {
let parser = Arc::clone(&parser);
let last_activity = Arc::clone(&last_activity);
let waker = Arc::clone(&waker);
thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => {
signal(&waker);
break;
}
Ok(n) => {
if let Ok(mut p) = parser.lock() {
p.process(&buf[..n]);
}
if let Ok(mut t) = last_activity.lock() {
*t = Instant::now();
}
signal(&waker);
}
}
}
})
};
Ok(Task {
id,
command: command.to_string(),
cwd: cwd.to_path_buf(),
master: pair.master,
writer,
child,
parser,
last_activity,
handle: Some(handle),
tagged: false,
exit_code: None,
started: Instant::now(),
finished: None,
term_sent: None,
})
}
pub fn poll_exit(&mut self) -> io::Result<()> {
if self.finished.is_none()
&& let Some(status) = self.child.try_wait()?
{
self.exit_code = Some(status.exit_code() as i32);
self.finished = Some(Instant::now());
}
Ok(())
}
pub fn lifecycle(&self, now: Instant, idle_after: Duration) -> Lifecycle {
if self.finished.is_some() {
return if self.exit_code == Some(0) {
Lifecycle::Ok
} else {
Lifecycle::Failed
};
}
let idle = self
.last_activity
.lock()
.map(|t| now.duration_since(*t) > idle_after)
.unwrap_or(false);
if idle {
Lifecycle::Idle
} else {
Lifecycle::Active
}
}
pub fn preview(&self) -> String {
let Ok(p) = self.parser.lock() else {
return String::new();
};
p.screen()
.contents()
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string()
}
pub fn formatted(&self) -> (Vec<u8>, (u16, u16), bool) {
match self.parser.lock() {
Ok(p) => {
let s = p.screen();
(s.contents_formatted(), s.cursor_position(), s.hide_cursor())
}
Err(_) => (Vec::new(), (0, 0), true),
}
}
pub fn screen_lines(&self) -> Vec<String> {
match self.parser.lock() {
Ok(p) => p.screen().contents().lines().map(str::to_string).collect(),
Err(_) => Vec::new(),
}
}
pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(io_err)?;
if let Ok(mut p) = self.parser.lock() {
p.screen_mut().set_size(rows, cols);
}
Ok(())
}
pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
self.writer.write_all(bytes)?;
self.writer.flush()
}
pub fn terminate(&mut self) {
if self.finished.is_none() && self.term_sent.is_none() {
if let Some(pid) = self.child.process_id() {
let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGTERM);
}
self.term_sent = Some(Instant::now());
}
}
pub fn overdue(&self, now: Instant, grace: Duration) -> bool {
self.finished.is_none()
&& self
.term_sent
.is_some_and(|t| now.duration_since(t) >= grace)
}
pub fn force_kill(&mut self) {
if self.finished.is_none() {
if let Some(pid) = self.child.process_id() {
let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGKILL);
}
let _ = self.child.kill();
}
self.handle.take(); }
}
impl Drop for Task {
fn drop(&mut self) {
self.force_kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn here() -> PathBuf {
std::env::current_dir().unwrap()
}
fn no_waker() -> Waker {
Arc::new(Mutex::new(None))
}
#[test]
fn spawn_reads_output_and_exits_zero() {
let mut t =
Task::spawn(1, "printf 'alpha\\nomega\\n'", &here(), 24, 80, no_waker()).unwrap();
let mut preview = String::new();
for _ in 0..100 {
t.poll_exit().unwrap();
preview = t.preview();
if t.finished.is_some() && preview.contains("omega") {
break;
}
thread::sleep(Duration::from_millis(20));
}
assert_eq!(t.exit_code, Some(0));
assert!(preview.contains("omega"), "preview was {preview:?}");
t.terminate();
}
#[test]
fn nonzero_exit_is_recorded() {
let mut t = Task::spawn(2, "exit 3", &here(), 24, 80, no_waker()).unwrap();
for _ in 0..100 {
t.poll_exit().unwrap();
if t.finished.is_some() {
break;
}
thread::sleep(Duration::from_millis(20));
}
assert_eq!(t.exit_code, Some(3));
assert_eq!(
t.lifecycle(Instant::now(), Duration::from_millis(600)),
Lifecycle::Failed
);
t.terminate();
}
#[test]
fn resize_is_reflected_in_the_grid() {
let mut t = Task::spawn(3, "sleep 5", &here(), 24, 80, no_waker()).unwrap();
t.resize(30, 100).unwrap();
assert_eq!(t.parser.lock().unwrap().screen().size(), (30, 100));
t.terminate();
}
}