#![cfg(unix)]
use std::path::Path;
use std::process::{Child, Stdio};
use std::time::{Duration, Instant};
use crate::common::{TestRepo, repo, wait_for_file_content};
use nix::errno::Errno;
use nix::sys::signal::kill;
use nix::unistd::Pid;
use rstest::rstest;
fn group_alive(pgid: i32) -> bool {
!matches!(kill(Pid::from_raw(-pgid), None), Err(Errno::ESRCH))
}
fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
cond()
}
fn spawn_tether(repo: &TestRepo, cwd: &Path, pidfile: &Path, tail: &str) -> (Child, i32) {
let snippet = format!(
"printf %s \"$$\" > \"{}\"; sleep 600 & {tail}",
pidfile.display()
);
let child = repo
.wt_command()
.args(["step", "tether", "--", "sh", "-c", &snippet])
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn wt step tether");
wait_for_file_content(pidfile);
let pgid: i32 = std::fs::read_to_string(pidfile)
.unwrap()
.trim()
.parse()
.expect("pgid in pidfile");
(child, pgid)
}
#[rstest]
fn test_tether_kills_process_group_when_worktree_removed(mut repo: TestRepo) {
for mechanism in ["delete", "rename"] {
let worktree = repo.add_worktree(&format!("server-{mechanism}"));
let pidfile = repo.home_path().join(format!("tether-{mechanism}.pgid"));
let (mut child, pgid) = spawn_tether(&repo, &worktree, &pidfile, "exec sleep 600");
assert!(
group_alive(pgid),
"[{mechanism}] supervised group should be alive before removal"
);
match mechanism {
"delete" => std::fs::remove_dir_all(&worktree).unwrap(),
"rename" => std::fs::rename(&worktree, repo.home_path().join("trash")).unwrap(),
_ => unreachable!(),
}
let group_gone = wait_until(|| !group_alive(pgid));
let supervisor_exited = wait_until(|| child.try_wait().unwrap().is_some());
let _ = kill(Pid::from_raw(-pgid), nix::sys::signal::Signal::SIGKILL);
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(repo.home_path().join("trash"));
assert!(
group_gone,
"[{mechanism}] removing the worktree must kill the whole process group"
);
assert!(
supervisor_exited,
"[{mechanism}] the tether supervisor must exit after teardown"
);
}
}
#[rstest]
fn test_tether_kills_process_group_when_command_exits(repo: TestRepo) {
let pidfile = repo.home_path().join("tether-selfexit.pgid");
let (mut child, pgid) = spawn_tether(&repo, repo.path(), &pidfile, "true");
let group_gone = wait_until(|| !group_alive(pgid));
let supervisor_exited = wait_until(|| child.try_wait().unwrap().is_some());
let _ = kill(Pid::from_raw(-pgid), nix::sys::signal::Signal::SIGKILL);
let _ = child.kill();
let _ = child.wait();
assert!(
group_gone,
"command self-exit must sweep the whole group, including the sidecar"
);
assert!(
supervisor_exited,
"the tether supervisor must exit after the command exits"
);
}