use std::io;
use std::io::{Read, Write};
use std::os::fd::{FromRawFd, OwnedFd};
use std::os::unix::io::AsRawFd;
use std::os::unix::process::CommandExt;
use std::pin::Pin;
use std::process::Stdio;
use std::task::{Context, Poll};
use tokio::io::unix::AsyncFd;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::process::{Child, Command};
use crate::sys::SpawnOptions;
use crate::sys::pid_gate::PidGate;
use super::{EofOnEio, PtyExitStatus, PtyReader, PtySpawn, PtyWriter};
pub(crate) struct PtyChild {
child: Child,
resize_fd: OwnedFd,
}
impl PtyChild {
pub(crate) fn id(&self) -> Option<u32> {
self.child.id()
}
pub(crate) fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
let winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe {
libc::ioctl(
self.resize_fd.as_raw_fd(),
libc::TIOCSWINSZ as _,
&raw const winsize,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(crate) async fn reap(&mut self, gate: &PidGate) -> io::Result<PtyExitStatus> {
use std::future::Future;
let mut wait = std::pin::pin!(self.child.wait());
let status = std::future::poll_fn(|cx| {
let mut out = std::task::Poll::Pending;
gate.reap_under_lock(|| match wait.as_mut().poll(cx) {
std::task::Poll::Ready(res) => {
out = std::task::Poll::Ready(res);
true
}
std::task::Poll::Pending => false,
});
out
})
.await?;
Ok(PtyExitStatus::from_std(status))
}
pub(crate) async fn wait(&mut self) -> io::Result<PtyExitStatus> {
self.child.wait().await.map(PtyExitStatus::from_std)
}
pub(crate) fn try_wait(&mut self) -> io::Result<Option<PtyExitStatus>> {
Ok(self.child.try_wait()?.map(PtyExitStatus::from_std))
}
pub(crate) fn start_kill(&mut self) -> io::Result<()> {
self.child.start_kill()
}
}
fn disable_echo(fd: &OwnedFd) -> io::Result<()> {
let raw = fd.as_raw_fd();
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(raw, &mut termios) } != 0 {
return Err(io::Error::last_os_error());
}
termios.c_lflag &= !(libc::ECHO | libc::ECHOE | libc::ECHOK | libc::ECHONL);
if unsafe { libc::tcsetattr(raw, libc::TCSANOW, &termios) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn terminal_eof(fd: &OwnedFd) -> io::Result<u8> {
let raw = fd.as_raw_fd();
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(raw, &mut termios) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(termios.c_cc[libc::VEOF])
}
fn open_pty(cols: u16, rows: u16) -> io::Result<(OwnedFd, OwnedFd)> {
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let mut winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
#[allow(clippy::unnecessary_mut_passed)]
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
&mut winsize,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
let master = unsafe { OwnedFd::from_raw_fd(master) };
let slave = unsafe { OwnedFd::from_raw_fd(slave) };
Ok((master, slave))
}
fn set_nonblocking(fd: &OwnedFd) -> io::Result<()> {
let raw = fd.as_raw_fd();
let flags = unsafe { libc::fcntl(raw, libc::F_GETFL) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[derive(Debug)]
struct AsyncPtyMaster {
master: AsyncFd<std::fs::File>,
eof_byte: Option<u8>,
eof_written: usize,
}
fn clone_master(fd: &OwnedFd, target: &'static str) -> io::Result<OwnedFd> {
#[cfg(not(test))]
let _ = target;
#[cfg(test)]
if let Some(error) = crate::sys::fault_injection::check(
crate::sys::fault_injection::Site::PtyMasterClone,
target,
) {
return Err(error);
}
fd.try_clone()
}
impl AsyncPtyMaster {
fn new(fd: OwnedFd, eof_byte: Option<u8>, target: &'static str) -> io::Result<Self> {
#[cfg(not(test))]
let _ = target;
set_nonblocking(&fd)?;
let file = std::fs::File::from(fd);
#[cfg(test)]
if let Some(error) = crate::sys::fault_injection::check(
crate::sys::fault_injection::Site::PtyAsyncFdRegistration,
target,
) {
return Err(error);
}
Ok(Self {
master: AsyncFd::new(file)?,
eof_byte,
eof_written: 0,
})
}
fn poll_write_raw(&mut self, cx: &mut Context<'_>, data: &[u8]) -> Poll<io::Result<usize>> {
loop {
let mut guard = match self.master.poll_write_ready(cx) {
Poll::Ready(Ok(guard)) => guard,
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
};
match guard.try_io(|inner| {
let mut file: &std::fs::File = inner.get_ref();
file.write(data)
}) {
Ok(result) => return Poll::Ready(result),
Err(_would_block) => continue,
}
}
}
}
impl AsyncRead for AsyncPtyMaster {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let this = self.get_mut();
loop {
let mut guard = match this.master.poll_read_ready(cx) {
Poll::Ready(Ok(guard)) => guard,
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
};
let unfilled = buf.initialize_unfilled();
match guard.try_io(|inner| {
let mut file: &std::fs::File = inner.get_ref();
file.read(unfilled)
}) {
Ok(Ok(n)) => {
buf.advance(n);
return Poll::Ready(Ok(()));
}
Ok(Err(e)) => return Poll::Ready(Err(e)),
Err(_would_block) => continue,
}
}
}
}
impl AsyncWrite for AsyncPtyMaster {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
data: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if this.eof_written > 0 {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"pty stdin writer closed",
)));
}
this.poll_write_raw(cx, data)
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.get_mut();
let Some(eof) = this.eof_byte else {
return Poll::Ready(Ok(()));
};
let sequence = [eof, eof];
while this.eof_written < sequence.len() {
let offset = this.eof_written;
match this.poll_write_raw(cx, &sequence[offset..]) {
Poll::Ready(Ok(0)) => {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::WriteZero,
"failed to deliver pty EOF",
)));
}
Poll::Ready(Ok(written)) => this.eof_written += written,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Pending => return Poll::Pending,
}
}
Poll::Ready(Ok(()))
}
}
impl Drop for AsyncPtyMaster {
fn drop(&mut self) {
let Some(eof) = self.eof_byte else {
return;
};
if self.eof_written < 2 {
let mut file: &std::fs::File = self.master.get_ref();
let _ = file.write(&[eof, eof][self.eof_written..]);
}
}
}
pub(crate) fn spawn_pty<F, R>(
cmd: &mut Command,
opts: &SpawnOptions,
spawn: F,
rollback: R,
) -> io::Result<PtySpawn>
where
F: FnOnce(&mut Command, &SpawnOptions) -> io::Result<Child>,
R: FnOnce(u32),
{
let (cols, rows) = opts.pty_size.unwrap_or(super::DEFAULT_PTY_SIZE);
let (master, slave) = open_pty(cols, rows)?;
disable_echo(&slave)?;
let eof_byte = terminal_eof(&slave)?;
let slave_out = slave.try_clone()?;
let slave_err = slave.try_clone()?;
cmd.stdin(Stdio::from(slave));
cmd.stdout(Stdio::from(slave_out));
cmd.stderr(Stdio::from(slave_err));
let mut pty_opts = *opts;
if !pty_opts.setsid {
unsafe {
cmd.as_std_mut().pre_exec(|| {
if libc::setsid() == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
});
}
}
pty_opts.setsid = true;
unsafe {
cmd.as_std_mut().pre_exec(|| {
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
});
}
let guard = PtySpawnRollback::new(spawn(cmd, &pty_opts)?, rollback);
let pid = guard.pid();
let master_w = clone_master(&master, "writer")?;
let master_resize = clone_master(&master, "resize")?;
let reader: PtyReader = Box::new(EofOnEio(AsyncPtyMaster::new(master, None, "reader")?));
let writer: PtyWriter = Box::new(AsyncPtyMaster::new(master_w, Some(eof_byte), "writer")?);
Ok(PtySpawn {
child: PtyChild {
child: guard.disarm(),
resize_fd: master_resize,
},
reader,
writer,
pid,
})
}
struct PtySpawnRollback<R: FnOnce(u32)> {
child: Option<Child>,
rollback: Option<R>,
}
impl<R: FnOnce(u32)> PtySpawnRollback<R> {
fn new(child: Child, rollback: R) -> Self {
Self {
child: Some(child),
rollback: Some(rollback),
}
}
fn pid(&self) -> Option<u32> {
self.child.as_ref().and_then(Child::id)
}
fn disarm(mut self) -> Child {
self.rollback.take();
self.child
.take()
.expect("the guarded child is taken exactly once")
}
}
impl<R: FnOnce(u32)> Drop for PtySpawnRollback<R> {
fn drop(&mut self) {
let Some(mut child) = self.child.take() else {
return; };
if let Some(pid) = child.id() {
if let Some(rollback) = self.rollback.take() {
rollback(pid);
}
let _ = child.start_kill();
}
drop(child);
}
}
#[cfg(test)]
mod tests {
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use super::*;
use crate::Mechanism;
use crate::runner::ProcessRunner;
use crate::sys::fault_injection::{Faults, Site};
use crate::sys::pgroup::ProcessGroup;
use crate::sys::{DisplacedSpare, SpawnOptions};
const ESCAPEE_FLAG: &str = "PK_PTY_ROLLBACK_ESCAPEE";
const ESCAPEE_PIDFILE: &str = "PK_PTY_ROLLBACK_PIDFILE";
const ESCAPEE_EXE: &str = "PK_PTY_ROLLBACK_EXE";
const ESCAPEE_SHELL_PID: &str = "PK_PTY_ROLLBACK_SHELL";
const ESCAPEE_TEST: &str = "sys::pty::imp::tests::setsid_escapee_process";
fn pty_options() -> SpawnOptions {
SpawnOptions {
use_pty: true,
..SpawnOptions::default()
}
}
fn platform_job() -> crate::sys::Job {
#[cfg(feature = "limits")]
let job = crate::sys::Job::new(&crate::limits::ResourceLimits::default());
#[cfg(not(feature = "limits"))]
let job = crate::sys::Job::new();
job.expect("create the platform job")
}
fn idle_command() -> Command {
let mut command = Command::new("sh");
command.args(["-c", "while :; do sleep 60; done"]);
command
}
fn pidfile(tag: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"processkit_pty_rollback_{tag}_{}.pid",
std::process::id()
));
let _ = std::fs::remove_file(&path);
path
}
fn is_alive(pid: u32) -> bool {
let pid = pid as libc::pid_t;
let mut status = 0;
unsafe {
if libc::waitpid(pid, &raw mut status, libc::WNOHANG) == pid {
return false; }
libc::kill(pid, 0) == 0
}
}
async fn wait_until_gone(pid: u32, what: &str) {
for _ in 0..600 {
if !is_alive(pid) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
panic!("{what}: pid {pid} was still alive after the bounded wait");
}
#[cfg(target_os = "linux")]
fn selective_leaf_kill_available(pid: u32) -> bool {
let Ok(text) = std::fs::read_to_string(format!("/proc/{pid}/cgroup")) else {
return false;
};
let Some(rel) = text.lines().find_map(|line| line.strip_prefix("0::")) else {
return false;
};
let dir = Path::new("/sys/fs/cgroup").join(rel.trim().trim_start_matches('/'));
dir.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("spawn-"))
&& dir.join("cgroup.kill").exists()
}
#[cfg(not(target_os = "linux"))]
fn selective_leaf_kill_available(_pid: u32) -> bool {
false
}
async fn assert_cgroup_escapee_scope(job: &crate::sys::Job, escapee: u32, selective: bool) {
if selective {
wait_until_gone(
escapee,
"the rollback's leaf-scoped cgroup.kill must reach a setsid escapee of \
the spawn it is undoing",
)
.await;
return;
}
assert!(
is_alive(escapee),
"without a per-spawn leaf the cgroup rollback is session-scoped by design; \
the escapee is the job's to kill, not this spawn's"
);
#[cfg(feature = "process-control")]
assert!(
job.members()
.expect("read the cgroup's members")
.contains(&escapee),
"the escapee must still be a cgroup member — neither setsid nor losing its \
parent takes a process out of a cgroup, which is why no orphan escapes the job"
);
job.kill_all().expect("the job's own teardown");
wait_until_gone(escapee, "the job's cgroup.kill must reach the escapee").await;
}
async fn published_pid(path: &Path, what: &str) -> u32 {
for _ in 0..600 {
if let Ok(text) = std::fs::read_to_string(path) {
let text = text.trim();
if !text.is_empty() {
return text.parse().unwrap_or_else(|_| {
panic!("{what} reported a failure, not a pid: {text}")
});
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("{what} never published its pid");
}
const PUBLISH_BUDGET: Duration = Duration::from_secs(20);
fn published_blocking(path: &Path, budget: Duration) -> Option<String> {
let deadline = Instant::now() + budget;
loop {
if let Ok(text) = std::fs::read_to_string(path) {
let text = text.trim().to_owned();
if !text.is_empty() {
return Some(text);
}
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn parse_published(published: Option<String>, what: &str) -> u32 {
match published {
None => panic!(
"{what} published nothing before the backend rollback ran — with the \
guard's contract order nothing had signalled the pty child yet, so \
either the direct SIGKILL moved back ahead of the callback or the \
re-exec filter no longer matches"
),
Some(text) => text
.parse()
.unwrap_or_else(|_| panic!("{what} reported a failure, not a pid: {text}")),
}
}
fn rolled_back_pty_spawn(
job: &crate::sys::Job,
command: &mut Command,
observe: impl FnOnce(u32),
) {
let _fault = Faults::new()
.fail_every(Site::PtyMasterClone, Some("writer"), libc::EIO)
.arm();
let result = spawn_pty(
command,
&pty_options(),
|cmd, opts| job.spawn(cmd, opts),
|pid| {
observe(pid);
job.rollback_pty_spawn(pid, DisplacedSpare::default());
},
);
assert!(
result.is_err(),
"the injected master-clone fault must surface as an error"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns real Unix PTY children"]
async fn post_spawn_pty_failures_kill_and_unregister_the_child() {
let failures = [
(Site::PtyMasterClone, Some("writer")),
(Site::PtyMasterClone, Some("resize")),
(Site::PtyAsyncFdRegistration, Some("reader")),
(Site::PtyAsyncFdRegistration, Some("writer")),
];
for (site, target) in failures {
let group = ProcessGroup::new();
let rollback_pid = Arc::new(AtomicU32::new(0));
let rollback_pid_for_callback = Arc::clone(&rollback_pid);
let _fault = Faults::new().fail_every(site, target, libc::EIO).arm();
let mut command = idle_command();
let result = spawn_pty(
&mut command,
&pty_options(),
|cmd, opts| group.spawn(cmd, opts),
|pid| {
rollback_pid_for_callback.store(pid, Ordering::SeqCst);
group.rollback_pty_spawn(pid, DisplacedSpare::default());
},
);
assert!(result.is_err(), "fault at {site:?}/{target:?} must surface");
let pid = rollback_pid.load(Ordering::SeqCst);
assert_ne!(pid, 0, "the rollback must observe the spawned child's pid");
#[cfg(feature = "process-control")]
assert!(
group.members().is_empty(),
"fault at {site:?}/{target:?} left a stale process-group entry"
);
wait_until_gone(pid, &format!("fault at {site:?}/{target:?}")).await;
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns real Unix PTY children"]
async fn a_failed_pty_launch_leaves_a_non_escalated_spare_in_place() {
let job = platform_job();
let file = pidfile("spare_survivor");
let mut command = Command::new("sh");
command
.args([
"-c",
"trap '' TERM; echo $$ > \"$PK_PIDFILE\"; while :; do sleep 60; done",
])
.env("PK_PIDFILE", &file);
let mut survivor = job
.spawn(&mut command, &SpawnOptions::default())
.expect("spawn the survivor");
let survivor_pid = survivor.id().expect("the survivor reports a pid");
assert_eq!(
published_pid(&file, "the survivor").await,
survivor_pid,
"the trap-installing shell must be the child this group tracks"
);
job.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.expect("graceful shutdown");
assert!(
is_alive(survivor_pid),
"a non-escalating shutdown must leave the survivor running"
);
{
let _fault = Faults::new()
.fail_every(Site::PtyMasterClone, Some("writer"), libc::EIO)
.arm();
let result = job.spawn_pty(&mut idle_command(), &pty_options(), None);
assert!(
result.is_err(),
"the injected master-clone fault must surface as an error"
);
}
drop(job);
tokio::time::sleep(Duration::from_millis(200)).await;
let spared = is_alive(survivor_pid);
unsafe { libc::kill(survivor_pid as libc::pid_t, libc::SIGKILL) };
let _ = survivor.wait().await;
let _ = std::fs::remove_file(&file);
assert!(
spared,
"a failed PTY launch must not undo a graceful_shutdown(escalate = false): \
the rollback has to restore the spare its own spawn displaced"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns real Unix PTY children"]
async fn post_spawn_pty_failure_releases_one_shot_stdin_reservation() {
let command = crate::Command::new("sh")
.args(["-c", "read value; test -z \"$value\""])
.stdin(crate::Stdin::from_reader(tokio::io::empty()))
.use_pty();
let fault = Faults::new()
.fail_every(Site::PtyMasterClone, Some("writer"), libc::EIO)
.arm();
let first = crate::JobRunner::new().start(&command).await;
assert!(first.is_err(), "the injected PTY setup fault must surface");
drop(fault);
let second = crate::JobRunner::new()
.output_string(&command)
.await
.expect("the restored one-shot stdin source must permit a retry");
assert!(
second.is_success(),
"the retry must receive EOF from the restored empty source: {second:?}"
);
}
#[tokio::test]
#[ignore = "spawns real Unix PTY children"]
async fn rollback_kills_a_descendant_forked_in_the_setup_window() {
let job = platform_job();
let file = pidfile("descendant");
let mut command = Command::new("sh");
command
.args(["-c", "sleep 300 & echo $! > \"$PK_PIDFILE\"; wait"])
.env("PK_PIDFILE", &file);
let spawn = job
.spawn_pty(&mut command, &pty_options(), None)
.expect("pty spawn");
let pid = spawn.pid.expect("the pty child reports a pid");
let descendant = published_pid(&file, "the forked descendant").await;
assert!(is_alive(descendant), "the descendant must start out alive");
job.rollback_pty_spawn(pid, DisplacedSpare::default());
wait_until_gone(
descendant,
"a descendant inside the pty child's session must not survive the rollback",
)
.await;
drop(spawn);
let _ = std::fs::remove_file(&file);
}
#[tokio::test]
#[ignore = "spawns real Unix PTY children and a setsid escapee"]
async fn rollback_scope_for_a_setsid_escapee_matches_the_mechanism() {
let job = platform_job();
let mechanism = job.mechanism();
let file = pidfile("escapee");
let exe = std::env::current_exe().expect("locate the unit-test binary");
let script = format!(
"\"${ESCAPEE_EXE}\" {ESCAPEE_TEST} --exact --ignored </dev/null >/dev/null 2>&1 & wait"
);
let mut command = Command::new("sh");
command
.args(["-c", &script])
.env(ESCAPEE_EXE, &exe)
.env(ESCAPEE_FLAG, "1")
.env(ESCAPEE_PIDFILE, &file);
let spawn = job
.spawn_pty(&mut command, &pty_options(), None)
.expect("pty spawn");
let pid = spawn.pid.expect("the pty child reports a pid");
let escapee = published_pid(&file, "the setsid escapee").await;
assert_eq!(
unsafe { libc::getsid(escapee as libc::pid_t) },
escapee as libc::pid_t,
"escapee {escapee} never became a session leader — the test would prove nothing"
);
let selective = selective_leaf_kill_available(escapee);
job.rollback_pty_spawn(pid, DisplacedSpare::default());
match mechanism {
Mechanism::ProcessReaper => {
wait_until_gone(
escapee,
"the reaper's subtree kill must reach a setsid escapee",
)
.await;
}
Mechanism::CgroupV2 => {
assert_cgroup_escapee_scope(&job, escapee, selective).await;
}
Mechanism::ProcessGroup => {
assert!(
is_alive(escapee),
"the documented process-group escape hatch: killpg cannot reach \
a new session, here as everywhere else on this mechanism"
);
unsafe { libc::kill(escapee as libc::pid_t, libc::SIGKILL) };
}
other => panic!("unexpected unix containment mechanism {other:?}"),
}
drop(spawn);
unsafe { libc::kill(escapee as libc::pid_t, libc::SIGKILL) };
let _ = std::fs::remove_file(&file);
}
#[tokio::test]
#[ignore = "spawns real Unix PTY children"]
async fn the_guard_rolls_the_backend_back_before_killing_the_child_itself() {
let job = platform_job();
let file = pidfile("guard");
let mut command = Command::new("sh");
command
.args(["-c", "sleep 300 & echo $! > \"$PK_PIDFILE\"; wait"])
.env("PK_PIDFILE", &file);
let published = RefCell::new(None);
let root_alive = Cell::new(false);
rolled_back_pty_spawn(&job, &mut command, |pid| {
*published.borrow_mut() = published_blocking(&file, PUBLISH_BUDGET);
root_alive.set(is_alive(pid));
});
let descendant = parse_published(published.into_inner(), "the forked descendant");
assert!(
root_alive.get(),
"the pty child was already dead when the backend rollback ran: the \
guard's direct SIGKILL must come last, or every mechanism's \
containment-scoped teardown is handed a tree whose root is a corpse"
);
wait_until_gone(
descendant,
"a descendant forked inside the setup window must not survive the guard",
)
.await;
let _ = std::fs::remove_file(&file);
}
#[tokio::test]
#[ignore = "spawns real Unix PTY children and a setsid escapee"]
async fn the_guard_rolls_back_a_spawn_whose_child_died_first() {
let job = platform_job();
let mechanism = job.mechanism();
let file = pidfile("orphaned_escapee");
let exe = std::env::current_exe().expect("locate the unit-test binary");
let script = format!(
"trap '' HUP; {ESCAPEE_SHELL_PID}=$$ \"${ESCAPEE_EXE}\" {ESCAPEE_TEST} \
--exact --ignored </dev/null >/dev/null 2>&1 & exit 0"
);
let mut command = Command::new("sh");
command
.args(["-c", &script])
.env(ESCAPEE_EXE, &exe)
.env(ESCAPEE_FLAG, "1")
.env(ESCAPEE_PIDFILE, &file);
let published = RefCell::new(None);
let selective = Cell::new(false);
rolled_back_pty_spawn(&job, &mut command, |_pid| {
let text = published_blocking(&file, PUBLISH_BUDGET);
if let Some(pid) = text.as_deref().and_then(|t| t.trim().parse::<u32>().ok()) {
selective.set(selective_leaf_kill_available(pid));
}
*published.borrow_mut() = text;
});
let escapee = parse_published(published.into_inner(), "the orphaned setsid escapee");
match mechanism {
Mechanism::ProcessReaper => {
wait_until_gone(
escapee,
"the reaper's subtree kill must reach a setsid escapee whose \
subtree root died before the rollback ran",
)
.await;
}
Mechanism::CgroupV2 => {
assert_cgroup_escapee_scope(&job, escapee, selective.get()).await;
}
Mechanism::ProcessGroup => {
assert!(
is_alive(escapee),
"the documented process-group escape hatch: killpg cannot reach \
a new session, here as everywhere else on this mechanism"
);
unsafe { libc::kill(escapee as libc::pid_t, libc::SIGKILL) };
}
other => panic!("unexpected unix containment mechanism {other:?}"),
}
unsafe { libc::kill(escapee as libc::pid_t, libc::SIGKILL) };
let _ = std::fs::remove_file(&file);
}
#[tokio::test]
#[ignore = "helper process for the rollback-scope test; a no-op unless re-exec'd"]
async fn setsid_escapee_process() {
let (Ok(_), Ok(pidfile)) = (std::env::var(ESCAPEE_FLAG), std::env::var(ESCAPEE_PIDFILE))
else {
return;
};
let sid = unsafe { libc::setsid() };
if sid == -1 {
let error = std::io::Error::last_os_error();
let _ = std::fs::write(&pidfile, format!("error: setsid failed: {error}"));
return;
}
if let Ok(shell) = std::env::var(ESCAPEE_SHELL_PID)
&& !await_orphaning(shell.parse().unwrap_or(0)).await
{
let _ = std::fs::write(&pidfile, format!("error: shell {shell} never exited"));
return;
}
let _ = std::fs::write(&pidfile, std::process::id().to_string());
tokio::time::sleep(Duration::from_secs(300)).await;
}
async fn await_orphaning(shell: libc::pid_t) -> bool {
for _ in 0..600 {
if unsafe { libc::getppid() } != shell {
return true;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
false
}
}