Test Ctrl-C, job control and hangup with an interactive shell on a terminal

This commit is contained in:
2026-11-04 02:50:35 +01:00
parent 04abf264a0
commit 5389ac97ac
5 changed files with 453 additions and 12 deletions
+171 -2
View File
@@ -1,7 +1,13 @@
use std::fs;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::ops::{Deref, DerefMut};
use std::process::Command;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::thread;
use std::time::{Duration, Instant};
use tempfile::TempDir;
@@ -107,3 +113,166 @@ impl std::ops::Deref for ConfigFile {
&self.path
}
}
pub fn wait_for_file(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(10);
while !path.exists() {
assert!(Instant::now() < deadline, "timed out waiting for {path:?}");
thread::sleep(Duration::from_millis(10));
}
}
pub struct Terminal {
master: Option<File>,
output: String,
session_leader: Child,
_guard: RwLockReadGuard<'static, ()>,
}
impl Terminal {
pub fn start(command_line: &[&str]) -> Self {
let guard = HostGlobsLock::for_scan();
let (master, slave) = open_pty();
let mut command = Command::new(command_line[0]);
command
.args(&command_line[1..])
.env("TERM", "dumb")
.stdin(Stdio::from(slave.try_clone().unwrap()))
.stdout(Stdio::from(slave.try_clone().unwrap()))
.stderr(Stdio::from(slave));
unsafe { command.pre_exec(take_terminal) };
let session_leader = command.spawn().expect("failed to start session leader");
Self {
master: Some(master),
output: String::new(),
session_leader,
_guard: guard,
}
}
pub fn type_text(&mut self, text: &str) {
self.master().write_all(text.as_bytes()).unwrap();
}
pub fn type_line(&mut self, line: &str) {
self.type_text(&format!("{line}\n"));
}
pub fn read_output(&mut self) -> &str {
self.read_available_output();
&self.output
}
pub fn wait_for_output(&mut self, text: &str) {
self.wait_until(&format!("output {text:?}"), |terminal| {
terminal.output.contains(text)
});
}
pub fn wait_until_shell_owns_terminal(&mut self) {
self.wait_until("shell in foreground", Self::is_shell_in_foreground);
}
pub fn wait_until_job_owns_terminal(&mut self) {
self.wait_until("job in foreground", |terminal| {
!terminal.is_shell_in_foreground()
});
}
pub fn wait_until_job_stopped(&mut self) {
self.wait_until("job stopped", Self::has_stopped_job);
}
pub fn hang_up(&mut self) {
self.master = None;
}
fn master(&mut self) -> &mut File {
self.master.as_mut().expect("terminal is hung up")
}
fn read_available_output(&mut self) {
let Some(master) = &mut self.master else {
return;
};
let mut buffer = [0; 4096];
while let Ok(count @ 1..) = master.read(&mut buffer) {
self.output
.push_str(&String::from_utf8_lossy(&buffer[..count]));
}
}
fn wait_until(&mut self, description: &str, condition: impl Fn(&Self) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
self.read_available_output();
if condition(self) {
return;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {description}, terminal output:\n{}",
self.output
);
thread::sleep(Duration::from_millis(10));
}
}
fn is_shell_in_foreground(&self) -> bool {
let master = self.master.as_ref().expect("terminal is hung up");
let foreground = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
foreground == self.session_leader.id() as i32
}
fn has_stopped_job(&self) -> bool {
let session = self.session_leader.id().to_string();
fs::read_dir("/proc")
.unwrap()
.filter_map(|entry| fs::read_to_string(entry.ok()?.path().join("stat")).ok())
.any(|stat| {
let fields_after_comm = &stat[stat.rfind(')').unwrap() + 1..];
let fields: Vec<&str> = fields_after_comm.split_whitespace().collect();
fields[0] == "T" && fields[3] == session
})
}
}
fn open_pty() -> (File, OwnedFd) {
let flags = libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC;
let master = unsafe { libc::posix_openpt(flags | libc::O_NONBLOCK) };
assert!(
master >= 0,
"posix_openpt failed: {}",
io::Error::last_os_error()
);
let master = unsafe { File::from_raw_fd(master) };
assert_eq!(
unsafe { libc::unlockpt(master.as_raw_fd()) },
0,
"unlockpt failed"
);
let slave = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGPTPEER, flags) };
assert!(
slave >= 0,
"TIOCGPTPEER failed: {}",
io::Error::last_os_error()
);
(master, unsafe { OwnedFd::from_raw_fd(slave) })
}
fn take_terminal() -> io::Result<()> {
if unsafe { libc::setsid() } == -1 || unsafe { libc::ioctl(0, libc::TIOCSCTTY, 0) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = self.session_leader.kill();
let _ = self.session_leader.wait();
}
}