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();
+}