diff --git a/issues/build/a-pipe-made-on-macos-can-leak-into-a-sibling-spawn-and-red-the-tether-tests.md b/issues/build/a-pipe-made-on-macos-can-leak-into-a-sibling-spawn-and-red-the-tether-tests.md new file mode 100644 index 0000000000..758b6f8813 --- /dev/null +++ b/issues/build/a-pipe-made-on-macos-can-leak-into-a-sibling-spawn-and-red-the-tether-tests.md @@ -0,0 +1,29 @@ +--- +status: open +kind: tooling +opened: 2026-09-29 +--- + +# A pipe made on macOS can leak into a sibling's spawn and red the tether tests + +`src/tether.rs`'s `Owner` judges its tethered children by the end of the +owner's stderr pipe: the end is every holder exited. On macOS, std makes that +pipe with `pipe()` and marks it close-on-exec afterwards +(`library/std/src/sys/pipe/unix.rs`: `pipe2` is only used on the targets that +have it, and macOS does not). A process another thread of the same test binary +spawns in that window inherits the write end and holds it until it exits. + +Both arms run beside sibling spawns: `a_tethered_child_dies_with_its_owner` +in `cargo test -p toyos-build --lib`, whose other tests spawn processes, and +`guest_dies_with_its_harness`, a `Sched::Parallel` test in the harness, beside +compiles and other guests' QEMUs. Such a leak turns a working tether into a +red: the pipe does not end within `WITHIN`, the owner's group is killed, and +the refusal says a holder outside that group still ran. +`toyos-tmpdir/tests/reclaim.rs` serialises its own spawns (`SPAWNING`) against +exactly this, and nothing here does. + +Owner: whoever next changes `src/tether.rs`. Exit condition: the verdict no +longer depends on the end of a pipe made on macOS without close-on-exec — the +pipe made atomically close-on-exec, or every spawn in each process that runs an +`Owner` serialised against its making, or a verdict read off something other +than a pipe's end. diff --git a/issues/build/the-build-system-does-not-compile-on-windows.md b/issues/build/the-build-system-does-not-compile-on-windows.md index ab6133ff86..6239c53df5 100644 --- a/issues/build/the-build-system-does-not-compile-on-windows.md +++ b/issues/build/the-build-system-does-not-compile-on-windows.md @@ -25,6 +25,8 @@ there. Every other crate in the graph, first-party and third-party, checked clean. The `#[cfg(unix)]` at `src/ci.rs:489` is still the only conditional compilation in the build system. +`src/tether.rs` is a fourth: `std::os::unix` and a pseudo-terminal per child, behind a Linux and macOS `cfg` pair with no Windows arm; `portability-windows` in run 36351950439 fails on it. + ## The judge, and it needs no Windows host and no download ``` diff --git a/src/lib.rs b/src/lib.rs index 625dc9dd33..27c7a4d3e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod soundfont; pub mod sourcegate; pub mod sysroot; pub mod testargs; +pub mod tether; pub mod tiers; pub mod toolchain; pub mod userlandhost; diff --git a/src/sourcegate.rs b/src/sourcegate.rs index e3e02dd9bc..16f4ecb252 100644 --- a/src/sourcegate.rs +++ b/src/sourcegate.rs @@ -629,9 +629,15 @@ const HOST_SPAWNS: &[Spawn] = &[ }, Spawn { arg: "std::env::current_exe().unwrap()", - sites: &[("src/buildlock.rs", 1), ("toyos-tmpdir/tests/reclaim.rs", 1)], - why: "a test binary re-running itself: the build system under the lock, and a \ - scratch holder whose death is what is judged", + sites: &[ + ("src/buildlock.rs", 1), + ("src/tether.rs", 1), + ("tests/common/orphan.rs", 1), + ("toyos-tmpdir/tests/reclaim.rs", 1), + ], + why: "a test binary re-running itself: the build system under the lock, and an \ + owner whose death is what is judged — of its scratch, its tethered child and \ + its guest", }, Spawn { arg: "env!(\"CARGO_BIN_EXE_toyos-ld\")", diff --git a/src/testargs.rs b/src/testargs.rs index fd11308331..f6d90879c6 100644 --- a/src/testargs.rs +++ b/src/testargs.rs @@ -163,6 +163,9 @@ declare_flags!(pub SUITE = { /// machine is not touched**: the run builds the images and writes down what /// to run on them, or judges readbacks a driver already left there. pub METAL_READBACK = "--metal-readback", Next; + /// The owner `guest_dies_with_its_harness` kills: the image it names, + /// booted and held until stdin ends. Alone on its line. + pub HOLD = "--hold", Next; }); /// Validate the harness's argv and return the run's filter. @@ -183,6 +186,7 @@ pub fn parse(args: &[String]) -> Result, String> { if let Some(refusal) = line.malformed() { return Err(refusal); } + let flags = line.seen.len(); let mut filter: Option<&str> = None; for word in line.positionals { @@ -236,6 +240,13 @@ pub fn parse(args: &[String]) -> Result, String> { } } } + if has(&HOLD) && (flags != 1 || filter.is_some()) { + return Err( + "--hold boots the image it names and holds it, and reads nothing else on the line; \ + every other word would be dropped in silence" + .to_string(), + ); + } Ok(filter) } @@ -510,6 +521,8 @@ mod tests { vec!["--debug"], vec!["--metal"], vec!["--metal", "--metal-readback", "target/metal"], + vec!["--hold", "boot.img"], + vec!["--hold=boot.img"], ] { assert!(parse_owned(&argv).is_ok(), "{argv:?}"); } @@ -525,4 +538,17 @@ mod tests { let refusal = parse_owned(&["--metal", "--audio-gate", "30"]).unwrap_err(); assert!(refusal.contains("cannot be combined"), "{refusal}"); } + + #[test] + fn hold_is_alone_on_its_line() { + for argv in [ + &["--hold", "boot.img", "boot"][..], + &["--hold", "boot.img", "--nightly"], + &["-j", "2", "--hold", "boot.img"], + &["--hold"], + ] { + let refusal = parse_owned(argv).unwrap_err(); + assert!(refusal.contains("--hold"), "{argv:?}: {refusal}"); + } + } } diff --git a/src/tether.rs b/src/tether.rs new file mode 100644 index 0000000000..6f3cdb5775 --- /dev/null +++ b/src/tether.rs @@ -0,0 +1,278 @@ +//! [`spawn`] makes the child the controlling process of a pseudo-terminal whose +//! master only the spawner holds. The kernel closes the master when the +//! spawner dies, by any signal, and a terminal whose master closes is hung up: +//! its controlling process gets `SIGHUP`, on Linux and on macOS. +//! `PR_SET_PDEATHSIG` is Linux's alone and follows the spawning thread rather +//! than the process, and QEMU's `exit-with-parent` is that on Linux and ends +//! QEMU alone. +//! +//! A process that ends on its own — a build, a one-shot client — is not +//! spawned here: a build ended mid-way can leave a toolchain half-written. + +use std::io::{self, BufRead, BufReader, Read}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +/// The master: dropping it hangs up the child's terminal. +pub struct Tether { + _master: OwnedFd, +} + +/// Spawn `cmd` as the controlling process of a terminal the returned +/// [`Tether`] holds; the child owes it an exit on `SIGHUP`, the inherited +/// descriptor kept open, and no session of its own. +pub fn spawn(mut cmd: Command) -> io::Result<(Child, Tether)> { + let flags = libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC; + // SAFETY: a NUL-terminated path, and the descriptor is checked before it is owned. + let master = unsafe { + let fd = libc::open(c"/dev/ptmx".as_ptr(), flags); + if fd < 0 { + return Err(io::Error::last_os_error()); + } + OwnedFd::from_raw_fd(fd) + }; + // SAFETY: `master` is an open pseudo-terminal master. + if unsafe { libc::grantpt(master.as_raw_fd()) != 0 || libc::unlockpt(master.as_raw_fd()) != 0 } { + return Err(io::Error::last_os_error()); + } + let slave = peer(&master, flags)?; + let fd = slave.as_raw_fd(); + // SAFETY: system calls on the child's own state, between `fork` and `exec`, + // allocating nothing. + unsafe { + cmd.pre_exec(move || { + // A mask and a disposition both survive `exec`, and a child that + // installs no handler of its own would ignore a blocked or ignored + // hangup. + let mut hup: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut hup); + libc::sigaddset(&mut hup, libc::SIGHUP); + if libc::sigprocmask(libc::SIG_UNBLOCK, &hup, std::ptr::null_mut()) != 0 + || libc::signal(libc::SIGHUP, libc::SIG_DFL) == libc::SIG_ERR + || libc::setsid() < 0 + || libc::ioctl(fd, libc::TIOCSCTTY as _, 0) < 0 + // Open across `exec`: macOS hangs up only a terminal somebody + // holds open. + || libc::fcntl(fd, libc::F_SETFD, 0) < 0 + { + return Err(io::Error::last_os_error()); + } + // No hangup precedes the terminal becoming this child's: the child + // holds its own copy of the master until `exec` closes it. + Ok(()) + }); + } + let child = cmd.spawn()?; + Ok((child, Tether { _master: master })) +} + +/// The slave of `master`, opened with `flags`. +#[cfg(target_os = "linux")] +fn peer(master: &OwnedFd, flags: libc::c_int) -> io::Result { + // SAFETY: `master` is an unlocked pseudo-terminal master. + let fd = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGPTPEER, flags) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: a descriptor the ioctl just opened. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +/// The slave of `master`, opened with `flags`. +#[cfg(target_os = "macos")] +fn peer(master: &OwnedFd, flags: libc::c_int) -> io::Result { + let mut name = [0 as libc::c_char; 128]; + // SAFETY: `TIOCPTYGNAME` writes a NUL-terminated name of at most 128 bytes. + let fd = unsafe { + if libc::ioctl(master.as_raw_fd(), libc::TIOCPTYGNAME as _, name.as_mut_ptr()) < 0 { + return Err(io::Error::last_os_error()); + } + libc::open(name.as_ptr(), flags) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: a descriptor `open` just returned. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +/// How long a step that waits on nothing but process creation and exit may +/// take: far above a spawn's or a hangup's, and a step that is stuck never ends. +const WITHIN: Duration = Duration::from_secs(10); + +/// An owner whose tethered children a test watches die with it. Dropped, it is +/// killed and reaped, so an owner a failed wait left running goes with the test. +pub struct Owner { + child: Child, + /// The owner's stdout, line by line; disconnected at its end. + said: Receiver>, + /// The owner's stderr, read to its end and sent here: its end is every + /// process that holds it exited, the owner's children among them. + closed: Receiver, +} + +impl Owner { + /// Spawn `cmd` as an owner: its stdin a pipe only this process writes, so + /// it ends when this process does; its stdout read by [`Self::said`]; in a + /// process group of its own, which a tethered child leaves and an + /// untethered one stays in; and `SIGHUP` blocked and ignored, the worst a + /// harness can hand down to what it spawns. + pub fn spawn(mut cmd: Command) -> Result { + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).process_group(0); + // SAFETY: two system calls on the child's own state, allocating nothing. + unsafe { + cmd.pre_exec(|| { + let mut hup: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut hup); + libc::sigaddset(&mut hup, libc::SIGHUP); + if libc::sigprocmask(libc::SIG_BLOCK, &hup, std::ptr::null_mut()) != 0 + || libc::signal(libc::SIGHUP, libc::SIG_IGN) == libc::SIG_ERR + { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = cmd.spawn().map_err(|e| format!("spawn the owner: {e}"))?; + let mut stderr = child.stderr.take().expect("a piped stderr"); + let (tx, closed) = mpsc::channel(); + std::thread::spawn(move || { + let mut text = Vec::new(); + let _ = stderr.read_to_end(&mut text); + let _ = tx.send(String::from_utf8_lossy(&text).into_owned()); + }); + let stdout = child.stdout.take().expect("a piped stdout"); + let (tx, said) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx.send(line).is_err() { + return; + } + } + }); + Ok(Owner { child, said, closed }) + } + + /// The rest of the first line the owner prints on stdout after `prefix`, + /// wherever on the line it starts: libtest may have begun the line. `Err` + /// if the owner ends first, or has not said it `within`. + pub fn said(&mut self, prefix: &str, within: Duration) -> Result { + let deadline = Instant::now() + within; + loop { + match self.said.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(line)) => { + if let Some((_, rest)) = line.split_once(prefix) { + return Ok(rest.to_string()); + } + } + Ok(Err(e)) => return Err(format!("read the owner's stdout: {e}")), + Err(RecvTimeoutError::Timeout) => { + return Err(format!("the owner had not said {prefix:?} within {within:?}")); + } + Err(RecvTimeoutError::Disconnected) => { + let stderr = self.closed.recv_timeout(WITHIN).unwrap_or_default(); + return Err(format!("the owner ended without saying {prefix:?}:\n{stderr}")); + } + } + } + } + + /// The owner's pid, which names what it leaves behind. + pub fn pid(&self) -> u32 { + self.child.id() + } + + /// `SIGKILL` the owner. `Err` if a process holding its stderr is still + /// there [`WITHIN`] afterward, naming which of `pids` still answer; the + /// owner's group is then killed, and the refusal says whether every + /// holder went with it. + pub fn killed(mut self, pids: &[u32]) -> Result<(), String> { + self.child.kill().map_err(|e| format!("SIGKILL the owner: {e}"))?; + // Held past the verdict: `wait` would close it, and a child reading + // it would end on that instead of on its tether. + let _stdin = self.child.stdin.take(); + let verdict = match self.closed.recv_timeout(WITHIN) { + Ok(_) => Ok(()), + Err(_) => Err(self.survived(pids)), + }; + self.child.wait().map_err(|e| format!("reap the owner: {e}"))?; + verdict + } + + /// What outlived the owner, asked before it is reaped: until then no other + /// process can hold its pid, so its group is what it spawned untethered. + fn survived(&self, pids: &[u32]) -> String { + // SAFETY: signal 0 asks whether the pid exists and delivers nothing. + let answered: Vec = + pids.iter().copied().filter(|&pid| unsafe { libc::kill(pid as i32, 0) } == 0).collect(); + // SAFETY: the group the unreaped owner leads. + let group = match unsafe { libc::killpg(self.child.id() as i32, libc::SIGKILL) } { + 0 => "was killed".to_string(), + _ => format!("could not be killed: {}", io::Error::last_os_error()), + }; + let after = if self.closed.recv_timeout(WITHIN).is_ok() { + "every holder of its stderr then exited".to_string() + } else { + format!("a holder of its stderr outside that group still ran {WITHIN:?} later") + }; + format!( + "a process holding the owner's stderr still ran {WITHIN:?} after the owner's SIGKILL; \ + of its tethered children {pids:?}, {answered:?} still answered; the owner's process \ + group {group}, and {after}" + ) + } +} + +impl Drop for Owner { + fn drop(&mut self) { + // `Ok` on an owner already reaped: its pid is not signalled again. + self.child.kill().expect("SIGKILL the owner"); + self.child.wait().expect("reap the owner"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TETHERED: &str = "tethered "; + + /// This test binary, running the one test `name` on one thread. + fn this_test(name: &str) -> Command { + let mut cmd = Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", name, "--include-ignored", "--nocapture", "--test-threads", "1"]); + cmd + } + + #[test] + #[ignore = "the owner `a_tethered_child_dies_with_its_owner` kills; never runs on its own"] + fn owner() { + let mut parked = this_test("tether::tests::parked"); + parked.stdout(Stdio::null()); + let (child, _tether) = spawn(parked).expect("spawn the tethered child"); + println!("{TETHERED}{}", child.id()); + io::stdin().read_to_end(&mut Vec::new()).expect("read the owner's stdin"); + } + + /// A child that never ends on its own while the test runs: its stdin is + /// the test's pipe, which the owner's death does not close. + #[test] + #[ignore = "the tethered child of `owner`; never runs on its own"] + fn parked() { + io::stdin().read_to_end(&mut Vec::new()).expect("read the parked child's stdin"); + } + + /// The owner's `SIGKILL` ends its tethered child, which inherited `SIGHUP` + /// blocked and ignored. + #[test] + fn a_tethered_child_dies_with_its_owner() { + let mut owner = Owner::spawn(this_test("tether::tests::owner")).unwrap_or_else(|e| panic!("{e}")); + let pid: u32 = + owner.said(TETHERED, WITHIN).unwrap_or_else(|e| panic!("{e}")).parse().expect("a pid"); + owner.killed(&[pid]).unwrap_or_else(|e| panic!("{e}")); + eprintln!("tethered child {pid} gone after its owner's SIGKILL"); + } +} diff --git a/tests/common/https.rs b/tests/common/https.rs index 198a04988a..3819d1e024 100644 --- a/tests/common/https.rs +++ b/tests/common/https.rs @@ -14,6 +14,7 @@ use std::time::Duration; use super::qemu::{self, BootOptions, QemuInstance}; use super::{compile, serial}; +use toyos_build::tether::Tether; /// Where the host arm sees the servers the guest reaches at /// [`qemu::GUEST_VIEW_OF_HOST`]. The judge's certificate carries both. @@ -277,6 +278,8 @@ fn fetch_on_host(url: &str, ca: &Path) -> Result { /// The host servers, killed when this goes out of scope. struct Server { child: Child, + /// What ends the servers when this process dies without dropping this. + _tether: Tether, ca: PathBuf, body_bytes: usize, body_sha: String, @@ -287,11 +290,9 @@ impl Server { fn start() -> Result { let out = super::lane::dir().join("https-judge"); std::fs::create_dir_all(&out).map_err(|e| format!("create {}: {e}", out.display()))?; - let mut child = Command::new(toyos_build::build::https_test_server(&compile::repo_root())) - .arg("--out") - .arg(&out) - .stdout(Stdio::piped()) - .spawn() + let mut cmd = Command::new(toyos_build::build::https_test_server(&compile::repo_root())); + cmd.arg("--out").arg(&out).stdout(Stdio::piped()); + let (mut child, tether) = toyos_build::tether::spawn(cmd) .map_err(|e| format!("start the judge's servers: {e}"))?; let stdout = child.stdout.take().expect("a piped stdout"); @@ -322,7 +323,7 @@ impl Server { let _ = child.kill(); return Err("the judge's servers never announced a CA and a body".to_string()); }; - Ok(Server { child, ca, body_bytes, body_sha, ports }) + Ok(Server { child, _tether: tether, ca, body_bytes, body_sha, ports }) } fn port(&self, role: &str) -> Result { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index aaeea0cc90..bf919c5593 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -45,6 +45,7 @@ pub mod logstream; pub mod metal; #[allow(dead_code)] pub mod origin; +pub mod orphan; #[allow(dead_code)] pub mod partclaim; #[allow(dead_code)] diff --git a/tests/common/orphan.rs b/tests/common/orphan.rs new file mode 100644 index 0000000000..643ba749a8 --- /dev/null +++ b/tests/common/orphan.rs @@ -0,0 +1,49 @@ +//! A guest a `SIGKILL`ed harness would leave running: the owner, and the test +//! that kills it and watches its QEMU go. + +use std::io::Read; +use std::path::Path; +use std::process::Command; + +use toyos_build::tether::Owner; +use toyos_build::testargs; +use toyos_tmpdir::TempDir; + +use super::qemu::{self, BootOptions, QemuInstance, Staged}; + +/// What the owner prints its QEMU's pid after. +const HELD: &str = "held: qemu "; + +/// `--hold `: boot `image`, print its QEMU's pid, and hold it until +/// stdin ends. +pub fn hold(test_config: &Path, image: &Path) { + let options = BootOptions { boot_image: Some(Staged::Pristine(image.to_path_buf())), ..Default::default() }; + let guest = QemuInstance::boot_with_options(test_config, &[], &[], options); + println!("{HELD}{}", guest.pid()); + std::io::stdin().read_to_end(&mut Vec::new()).expect("read the owner's stdin"); +} + +/// The harness's `SIGKILL` ends its guest, whose QEMU inherited `SIGHUP` +/// blocked and ignored. +pub fn guest_dies_with_its_harness(test_config: &Path) -> Result<(), String> { + let tmp = TempDir::new("orphan"); + let short = TempDir::short("orphan"); + let image = tmp.join("boot.img"); + std::fs::write(&image, qemu::build_boot_image(test_config, &[], &[], &[])) + .map_err(|e| format!("write {}: {e}", image.display()))?; + let mut owner = Command::new(std::env::current_exe().unwrap()); + owner.arg(testargs::HOLD.name).arg(&image).env("TMPDIR", &tmp); + let mut owner = Owner::spawn(owner)?; + let owner_pid = owner.pid(); + let verdict = (|| { + // The owner builds nothing, so its one wait is its boot, whose own + // ceiling ends it well inside the backstop on any wait on a guest. + let pid: u32 = + owner.said(HELD, qemu::GUEST_WEDGED)?.parse().map_err(|e| format!("the owner's QEMU pid: {e}"))?; + owner.killed(&[pid]).map(|()| pid) + })(); + short.adopt(Path::new(toyos_tmpdir::SHORT_BASE), owner_pid); + let pid = verdict?; + eprintln!(" [orphan] QEMU {pid} gone after its harness's SIGKILL"); + Ok(()) +} diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index f8a6d81435..6840d19bfb 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -10,6 +10,7 @@ use std::{fs, thread}; use super::compile; use toyos_build::arch::{Accel, Arch}; +use toyos_build::tether::Tether; use toyos_tmpdir::TempDir; /// The architecture every machine this suite builds and boots is: the suite's @@ -2624,6 +2625,8 @@ impl ConsoleStream { pub struct QemuInstance { child: Child, + /// What ends QEMU when this process dies without dropping this. + _tether: Tether, stdin: BufWriter>, rx: Receiver, console: ConsoleStream, @@ -3353,6 +3356,10 @@ impl QemuInstance { } } + pub fn pid(&self) -> u32 { + self.child.id() + } + /// Every console line the guest printed before the ready marker. /// /// The kernel's own boot lines sit in the log ring until the scheduler @@ -4908,6 +4915,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) console_file, } = files; + // Inherited: `orphan` reads QEMU's exit as the end of its harness's stderr. qemu.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); @@ -4923,7 +4931,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) if VERBOSE.load(Ordering::Relaxed) { eprintln!("[qemu {seq}] Launching QEMU..."); } - let mut child = qemu.spawn().expect("Failed to launch QEMU"); + let (mut child, tether) = toyos_build::tether::spawn(qemu).expect("Failed to launch QEMU"); let stdin: Box = match input { Some(fifo) => Box::new(fifo), @@ -5006,6 +5014,7 @@ fn spawn_and_wait_ready(mut qemu: Command, options: &BootOptions, files: Files) LIVE.fetch_add(1, Ordering::SeqCst); QemuInstance { child, + _tether: tether, stdin, rx, _reader_thread: reader_thread, diff --git a/tests/toyos.rs b/tests/toyos.rs index ff90ef5aae..d56a5cfa44 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -598,6 +598,7 @@ const GRAFFITI: [u8; 3] = [0x00, 0xC0, 0x00]; /// tidy. const MACHINE_TESTS: &[(&str, Sched, Tier)] = &[ ("ioapic_topology", Sched::Parallel, Tier::Fast), + ("guest_dies_with_its_harness", Sched::Parallel, Tier::Fast), // The interrupt census adds up, and every device interrupt is still cpu0's. // **The second half is what makes this the track's instrument rather than a // tidiness check**: it states the present-state fact @@ -13695,6 +13696,7 @@ fn run_machine_test( control_regs(qemu.boot_log(), CPUS) } "control_regs_negative" => control_regs_negative(test_config, c_bins, rust_bins), + "guest_dies_with_its_harness" => common::orphan::guest_dies_with_its_harness(test_config), "smp_roster_and_tsc_trail" => { // Eight, which is the T14's own count and this suite's ceiling. const CPUS: u32 = 8; @@ -19107,6 +19109,14 @@ fn main() { // this run's scratch, green or red; taking it reclaims what killed runs left. let run = common::lane::Run::begin(); + if let Some(image) = SUITE.value(&args, &testargs::HOLD) { + common::orphan::hold( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/testcases"), + Path::new(image), + ); + run.exit(0); + } + check_registration(); if nocapture || debug_mode { diff --git a/toyos-tmpdir/src/lib.rs b/toyos-tmpdir/src/lib.rs index efa210f8a7..456e1000eb 100644 --- a/toyos-tmpdir/src/lib.rs +++ b/toyos-tmpdir/src/lib.rs @@ -110,6 +110,18 @@ impl TempDir { pub fn path(&self) -> &Path { &self.path } + + /// Move every root under `base` that process `pid` made and is gone from + /// into this directory, so it goes when this does: for a caller that + /// `SIGKILL`ed `pid` after this process's one sweep of `base`. A live root + /// is left alone, whatever its name. + pub fn adopt(&self, base: &Path, pid: u32) { + let prefix = format!("{ROOT_PREFIX}{pid}-"); + let _global = global(base); + reap_into(base, &self.path, |root| { + root.file_name().expect("a root has a name").to_string_lossy().starts_with(&prefix) + }); + } } impl Deref for TempDir { @@ -284,18 +296,7 @@ impl State { self.swept = true; let root = self.root.as_ref().expect("a sweep runs from a live root"); let _global = global(&root.tmp); - let mut reap = Vec::new(); - for path in gone_under(&root.tmp) { - if path == root.dir { - continue; - } - let name = path.file_name().expect("a root has a name").to_string_lossy(); - let to = root.dir.join(format!("reap-{name}")); - fs::rename(&path, &to) - .unwrap_or_else(|e| panic!("move {} to {}: {e}", path.display(), to.display())); - reap.push(to); - } - reap + reap_into(&root.tmp, &root.dir, |path| path != root.dir) } } @@ -306,6 +307,23 @@ pub fn gone_roots(base: &Path) -> Vec { gone_under(base) } +/// Every gone root under `base` that `take` passes, renamed into `dest` as +/// `reap-`; the caller holds [`GLOBAL`]. +fn reap_into(base: &Path, dest: &Path, take: impl Fn(&Path) -> bool) -> Vec { + let mut reap = Vec::new(); + for path in gone_under(base) { + if !take(&path) { + continue; + } + let name = path.file_name().expect("a root has a name").to_string_lossy(); + let to = dest.join(format!("reap-{name}")); + fs::rename(&path, &to) + .unwrap_or_else(|e| panic!("move {} to {}: {e}", path.display(), to.display())); + reap.push(to); + } + reap +} + /// [`gone_roots`], with [`GLOBAL`] held by the caller. fn gone_under(base: &Path) -> Vec { let entries = fs::read_dir(base).unwrap_or_else(|e| panic!("read {}: {e}", base.display())); diff --git a/toyos-tmpdir/tests/reclaim.rs b/toyos-tmpdir/tests/reclaim.rs index 45786f84f9..b689a3caff 100644 --- a/toyos-tmpdir/tests/reclaim.rs +++ b/toyos-tmpdir/tests/reclaim.rs @@ -314,3 +314,48 @@ fn a_directory_the_sweep_cannot_remove_is_reported_and_the_next_process_still_wo perms.set_mode(0o755); std::fs::set_permissions(&stuck_locked, perms).unwrap(); } + +/// A killed process's root goes into the directory that adopts its pid, and +/// goes when that does; a live root, and a gone one of a pid that merely +/// starts with the same digits, stay. +#[test] +fn an_adopted_root_goes_with_its_adopter() { + let _spawning = spawning(); + let tmp = TempDir::new("adopt"); + let live = Holder::start(&tmp); + let killed = Holder::start(&tmp); + let (pid, root, held) = (killed.child.id(), killed.root(), killed.dir.clone()); + killed.kill(); + let other = tmp.join(format!("{ROOT_PREFIX}{pid}0-0")); + std::fs::create_dir(&other).unwrap(); + + // The adopted pid reused, so its name matches the prefix `adopt` filters + // on; only its owner's lock says it is live, and that must still hold. + let reused = tmp.join(format!("{ROOT_PREFIX}{pid}-9")); + std::fs::create_dir(&reused).unwrap(); + let reused_owner = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(reused.join(OWNER)) + .unwrap(); + reused_owner.lock().unwrap(); + + let adopter = TempDir::new("adopter"); + adopter.adopt(&tmp, pid); + assert!(!root.exists(), "{} was not adopted", root.display()); + let moved = adopter + .join(format!("reap-{}", root.file_name().unwrap().to_string_lossy())) + .join(held.file_name().unwrap()) + .join("image.img"); + assert!(moved.exists(), "{} holds no {}", adopter.display(), moved.display()); + assert!(other.exists(), "another pid's root was adopted"); + assert!(live.dir.join("image.img").exists(), "a live root was adopted"); + assert!(reused.exists(), "a live root of a reused pid was adopted"); + + drop(reused_owner); + let adopted = adopter.to_path_buf(); + drop(adopter); + assert!(!adopted.exists(), "{} outlived its TempDir", adopted.display()); + live.finish(); +}