use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::{Mutex, MutexGuard, PoisonError};
use nix::sys::signal::{SigSet, SigmaskHow, Signal as NixSignal};
use rustix::fs::{Mode, OFlags};
use rustix::process::{
Pid, Signal, WaitId, WaitIdOptions, getpgrp, kill_current_process_group, kill_process_group, waitid,
};
use rustix::termios::{tcgetpgrp, tcsetpgrp};
static FOREGROUND: Mutex<()> = Mutex::new(());
pub(crate) fn status(command: &mut Command) -> io::Result<ExitStatus> {
let (mut child, foreground) = Foreground::spawn(command)?;
let status = foreground.wait(&mut child);
let taken = foreground.give_back();
let status = status?;
taken?;
Ok(status)
}
pub(crate) struct Foreground {
lent: Option<(Terminal, Pid)>,
_only_one: MutexGuard<'static, ()>,
}
impl Foreground {
pub(crate) fn spawn(command: &mut Command) -> io::Result<(Child, Self)> {
let only_one = FOREGROUND.lock().unwrap_or_else(PoisonError::into_inner);
let Some(terminal) = Terminal::open() else {
return Ok((command.spawn()?, Self { lent: None, _only_one: only_one }));
};
command.process_group(0);
let mut child = command.spawn()?;
let group = Pid::from_child(&child);
if let Err(error) = tcsetpgrp(&terminal.device, group) {
if let Ok(Some(_)) = child.try_wait() {
return Ok((child, Self { lent: None, _only_one: only_one }));
}
let _ = kill_process_group(group, Signal::KILL);
let _ = child.wait();
return Err(error.into());
}
let _ = kill_process_group(group, Signal::CONT);
Ok((child, Self { lent: Some((terminal, group)), _only_one: only_one }))
}
pub(crate) fn wait(&self, child: &mut Child) -> io::Result<ExitStatus> {
match &self.lent {
Some((_, group)) => wait_through_stops(child, *group),
None => child.wait(),
}
}
pub(crate) fn check(&self, child: &mut Child) -> io::Result<Option<ExitStatus>> {
if let Some((_, group)) = &self.lent {
let pid = Pid::from_child(child);
let options = WaitIdOptions::STOPPED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT;
if let Ok(Some(state)) = waitid(WaitId::Pid(pid), options)
&& state.stopped()
{
let _ = waitid(WaitId::Pid(pid), WaitIdOptions::STOPPED | WaitIdOptions::NOHANG);
let _ = kill_process_group(*group, Signal::CONT);
}
}
child.try_wait()
}
pub(crate) fn give_back(mut self) -> io::Result<()> {
match self.lent.take() {
Some((terminal, _)) => terminal.take_back(),
None => Ok(()),
}
}
}
impl Drop for Foreground {
fn drop(&mut self) {
if let Some((terminal, _)) = self.lent.take() {
let _ = terminal.take_back();
}
}
}
fn wait_through_stops(child: &mut Child, group: Pid) -> io::Result<ExitStatus> {
let pid = Pid::from_child(child);
loop {
match waitid(WaitId::Pid(pid), WaitIdOptions::EXITED | WaitIdOptions::STOPPED | WaitIdOptions::NOWAIT) {
Ok(Some(state)) if state.stopped() => {
let _ = waitid(WaitId::Pid(pid), WaitIdOptions::STOPPED | WaitIdOptions::NOHANG);
let _ = kill_process_group(group, Signal::CONT);
}
Err(rustix::io::Errno::INTR) => {}
_ => return child.wait(),
}
}
}
struct Terminal {
device: OwnedFd,
group: Pid,
}
impl Terminal {
fn open() -> Option<Self> {
let device =
rustix::fs::open("/dev/tty", OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty()).ok()?;
let group = getpgrp();
(tcgetpgrp(&device).ok()? == group).then_some(Self { device, group })
}
fn take_back(self) -> io::Result<()> {
let mut ttou = SigSet::empty();
ttou.add(NixSignal::SIGTTOU);
let previous = ttou.thread_swap_mask(SigmaskHow::SIG_BLOCK).map_err(io::Error::from)?;
let taken = tcsetpgrp(&self.device, self.group);
previous.thread_set_mask().map_err(io::Error::from)?;
taken?;
let _ = kill_current_process_group(Signal::CONT);
Ok(())
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::runtime::HandoffOutcome;
use crate::runtime::handoff::{self, Handoff, HandoffScreen};
const DIR_VAR: &str = "QUVYTA_FOREGROUND_TEST_DIR";
const PATIENCE: Duration = Duration::from_secs(60);
const PROGRAM: &str = r#"
set -- $(cat /proc/$$/stat); echo "$5 $6 $7" > "$D/child"
while set -- $(cat /proc/$$/stat); [ "$5" != "$8" ]; do sleep 0.01; done
trap 'echo on >> "$D/continued"' CONT
trap 'echo interrupted > "$D/interrupted"; exit 42' INT
touch "$D/ready"
while :; do sleep 0.05; done
"#;
fn stat_ids(pid: &str) -> String {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("stat");
let rest = &stat[stat.rfind(')').expect("name") + 2..];
let fields: Vec<&str> = rest.split(' ').collect();
format!("{} {} {}", fields[2], fields[3], fields[4])
}
fn signal_state() -> Vec<String> {
std::fs::read_to_string("/proc/thread-self/status")
.expect("status")
.lines()
.filter(|line| ["SigBlk", "SigIgn", "SigCgt"].iter().any(|name| line.starts_with(name)))
.map(str::to_owned)
.collect()
}
fn in_the_foreground() -> bool {
let device = std::fs::OpenOptions::new().read(true).write(true).open("/dev/tty").expect("a terminal");
rustix::termios::tcgetpgrp(&device).expect("its foreground") == rustix::process::getpgrp()
}
#[test]
#[ignore = "started inside a pseudo-terminal by the tests below"]
fn inside_a_terminal() {
let dir = PathBuf::from(std::env::var_os(DIR_VAR).expect("started by the tests below, not directly"));
assert!(in_the_foreground(), "the test starts as the terminal's foreground");
let before = signal_state();
let ours = stat_ids("self");
let foreground_at_take = std::cell::Cell::new(false);
let mut release = |_: Option<&str>| Ok(());
let mut take = || {
foreground_at_take.set(in_the_foreground());
Ok(())
};
let mut wait_for_key = || Ok(());
let outcome = handoff::run(
Handoff::new("sh", |outcome| outcome).args(["-c", PROGRAM]).env("D", &dir),
&mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
);
assert_eq!(outcome, HandoffOutcome::Finished { code: Some(42) }, "the program's own trap ended it");
assert!(dir.join("interrupted").exists(), "`Ctrl-C` reached the program");
assert!(foreground_at_take.get(), "the terminal was the application's again when it took the screen back");
assert_eq!(signal_state(), before, "no signal disposition or mask of the application changed");
assert_eq!(stat_ids("self"), ours, "the application is still in its own group");
let child = std::fs::read_to_string(dir.join("child")).expect("the program recorded itself");
let [group, session, terminal] = child.split_whitespace().collect::<Vec<_>>()[..] else {
panic!("three fields: {child}");
};
let [our_group, our_session, our_terminal] = ours.split(' ').collect::<Vec<_>>()[..] else {
panic!("three fields: {ours}");
};
assert_ne!(group, our_group, "the program ran in a group of its own");
assert_eq!(session, our_session, "the same session, which a sudo ticket is kept for");
assert_eq!(terminal, our_terminal, "the same controlling terminal, which a sudo ticket is kept for");
}
const DETACHED_PROGRAM: &str = r#"
set -- $(cat /proc/$$/stat); echo "$5 $6 $7" > "$D/child"
while set -- $(cat /proc/$$/stat); [ "$5" != "$8" ]; do sleep 0.01; done
trap 'echo on >> "$D/continued"' CONT
trap 'echo interrupted > "$D/interrupted"; echo ready; exec cat' INT
touch "$D/ready"
while :; do sleep 0.05; done
"#;
#[test]
#[ignore = "started inside a pseudo-terminal by the tests below"]
fn detached_inside_a_terminal() {
use crate::runtime::task::Delivery;
use crate::runtime::{ChildLine, DetachedHandoff, DetachedOutcome};
#[derive(Debug)]
enum Heard {
Started(DetachedOutcome),
Said(ChildLine),
}
let dir = PathBuf::from(std::env::var_os(DIR_VAR).expect("started by the tests below, not directly"));
assert!(in_the_foreground(), "the test starts as the terminal's foreground");
let before = signal_state();
let ours = stat_ids("self");
let foreground_at_take = std::cell::Cell::new(false);
let mut release = |_: Option<&str>| Ok(());
let mut take = || {
foreground_at_take.set(in_the_foreground());
Ok(())
};
let mut wait_for_key = || Ok(());
let (deliveries, lines) = std::sync::mpsc::channel();
let message = crate::runtime::detached::run(
DetachedHandoff::new("sh", Heard::Started)
.args(["-c", DETACHED_PROGRAM])
.env("D", &dir)
.on_line(Heard::Said),
&mut HandoffScreen { release: &mut release, take: &mut take, wait_for_key: &mut wait_for_key },
&deliveries,
);
let Heard::Started(DetachedOutcome::Detached { child, first_line }) = message else {
panic!("the program said it was ready after Ctrl-C: {message:?}");
};
assert_eq!(first_line, "ready");
assert!(dir.join("interrupted").exists(), "`Ctrl-C` reached the program, not the application");
assert!(foreground_at_take.get(), "the terminal was the application's again when it took the screen back");
assert!(in_the_foreground(), "and it stays the application's while the child runs on");
assert_eq!(signal_state(), before, "no signal disposition or mask of the application changed");
assert_eq!(stat_ids("self"), ours, "the application is still in its own group");
let pid = child.id().expect("a real child").to_string();
let theirs = stat_ids(&pid);
let [group, session, _] = theirs.split(' ').collect::<Vec<_>>()[..] else {
panic!("three fields: {theirs}");
};
let [our_group, our_session, _] = ours.split(' ').collect::<Vec<_>>()[..] else {
panic!("three fields: {ours}");
};
assert_ne!(group, our_group, "the child runs on in a group of its own, in the background");
assert_eq!(session, our_session, "in the application's session");
child.write_line("still here").expect("the child reads its input");
let said = |expected: ChildLine| match lines.recv_timeout(PATIENCE) {
Ok(Delivery::Message(Heard::Said(line))) => assert_eq!(line, expected),
Ok(Delivery::Message(other)) => panic!("expected {expected:?}, got {other:?}"),
Ok(Delivery::Ended) => panic!("expected {expected:?}, got the end of background work"),
Err(error) => panic!("expected {expected:?}: {error}"),
};
said(ChildLine::Line("still here".to_owned()));
drop(child);
said(ChildLine::Ended { code: Some(0) });
}
#[test]
#[ignore = "started inside a pseudo-terminal by the tests below"]
fn panic_inside_a_terminal() {
assert!(in_the_foreground(), "the test starts as the terminal's foreground");
let (mut child, foreground) = super::Foreground::spawn(Command::new("sleep").arg("30")).expect("sleep starts");
assert!(!in_the_foreground(), "the program owns the terminal");
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _lent = foreground;
panic!("a panic while the program owns the terminal");
}));
assert!(unwound.is_err());
assert!(in_the_foreground(), "unwinding gave the terminal back");
child.kill().expect("kill");
child.wait().expect("wait");
}
fn pseudo_terminal() -> (std::fs::File, std::os::fd::OwnedFd) {
use rustix::fs::{Mode, OFlags};
use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
let controller = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).expect("openpt");
grantpt(&controller).expect("grantpt");
unlockpt(&controller).expect("unlockpt");
let name = ptsname(&controller, Vec::new()).expect("ptsname");
let device = rustix::fs::open(name, OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty())
.expect("the terminal device");
(std::fs::File::from(controller), device)
}
fn wait_for(path: &Path, what: &str, output: &Mutex<Vec<u8>>) {
let started = Instant::now();
while !path.exists() {
assert!(
started.elapsed() < PATIENCE,
"{what} never happened; the terminal showed:\n{}",
String::from_utf8_lossy(&output.lock().expect("output"))
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn press_keys_during_a_handoff(name: &str, launcher: &[&str]) {
in_a_terminal("inside_a_terminal", name, launcher, true);
}
fn in_a_terminal(inner: &str, name: &str, launcher: &[&str], keys: bool) {
let dir = std::env::temp_dir().join(format!("quvyta-foreground-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
let (mut controller, device) = pseudo_terminal();
let test = format!("{}::{inner}", module_path!().split_once("::").expect("crate path").1);
let exe = std::env::current_exe().expect("the test binary");
let mut command = Command::new("setsid");
command
.arg("--ctty")
.args(launcher)
.arg(&exe)
.args([test.as_str(), "--exact", "--ignored", "--test-threads=1"])
.env(DIR_VAR, &dir)
.stdin(Stdio::from(device.try_clone().expect("clone")))
.stdout(Stdio::from(device.try_clone().expect("clone")))
.stderr(Stdio::from(device));
let mut session = command.spawn().expect("setsid starts");
drop(command);
let output = Arc::new(Mutex::new(Vec::new()));
let reader = {
let mut controller = controller.try_clone().expect("clone");
let output = Arc::clone(&output);
std::thread::spawn(move || {
let mut chunk = [0_u8; 4096];
while let Ok(count @ 1..) = controller.read(&mut chunk) {
output.lock().expect("output").extend_from_slice(&chunk[..count]);
}
})
};
if keys {
wait_for(&dir.join("ready"), "the program owning the terminal", &output);
let continued = dir.join("continued");
let lines = |path: &Path| std::fs::read_to_string(path).unwrap_or_default().lines().count();
let before = lines(&continued);
controller.write_all(b"\x1a").expect("Ctrl-Z");
let started = Instant::now();
while lines(&continued) == before {
assert!(
started.elapsed() < PATIENCE,
"the program never stopped and went on again; the terminal showed:\n{}",
String::from_utf8_lossy(&output.lock().expect("output"))
);
std::thread::sleep(Duration::from_millis(10));
}
controller.write_all(b"\x03").expect("Ctrl-C");
}
let started = Instant::now();
let status = loop {
if let Some(status) = session.try_wait().expect("wait") {
break status;
}
if started.elapsed() > PATIENCE {
let _ = session.kill();
let _ = session.wait();
panic!("the session never ended:\n{}", String::from_utf8_lossy(&output.lock().expect("output")));
}
std::thread::sleep(Duration::from_millis(20));
};
drop(controller);
reader.join().expect("the reader of the terminal");
let shown = String::from_utf8_lossy(&output.lock().expect("output")).into_owned();
assert!(status.success(), "the test inside the terminal failed ({status}):\n{shown}");
assert!(shown.contains("1 passed"), "the test inside the terminal ran:\n{shown}");
std::fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn keys_reach_the_program_when_the_application_leads_its_session() {
press_keys_during_a_handoff("leader", &[]);
}
#[test]
fn keys_reach_the_program_when_a_shell_started_the_application() {
press_keys_during_a_handoff("member", &["sh", "-c", r#""$0" "$@"; exit $?"#]);
}
#[test]
fn a_detached_program_gets_the_keys_until_it_is_ready_and_then_runs_in_the_background() {
in_a_terminal("detached_inside_a_terminal", "detached", &[], true);
}
#[test]
fn a_panic_while_a_program_owns_the_terminal_gives_it_back() {
in_a_terminal("panic_inside_a_terminal", "panic", &[], false);
}
}