use std::io;
use std::os::fd::OwnedFd;
use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::Mutex;
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 _only_one = FOREGROUND.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(terminal) = Terminal::open() else {
return command.status();
};
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(status)) = child.try_wait() {
return Ok(status);
}
let _ = kill_process_group(group, Signal::KILL);
let _ = child.wait();
return Err(error.into());
}
let _ = kill_process_group(group, Signal::CONT);
let status = wait_through_stops(&mut child, group);
let taken = terminal.take_back();
let status = status?;
taken?;
Ok(status)
}
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 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");
}
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]) {
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!("{}::inside_a_terminal", 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]);
}
})
};
wait_for(&dir.join("ready"), "the program owning the terminal", &output);
controller.write_all(b"\x1a").expect("Ctrl-Z");
std::thread::sleep(Duration::from_millis(300));
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);
let shown = String::from_utf8_lossy(&output.lock().expect("output")).into_owned();
drop(reader);
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 $?"#]);
}
}