Files
max-sixty__worktrunk/tests/integration_tests/step_tether.rs
Maximilian Roos 5ce076c22c fix(step): apply -C to the tethered command; tether the docs server (#3207)
Make `wt step tether` honor the global `-C` flag as the spawned command's working directory (consistent with `run_custom`), and use it to tether this repo's `zola serve` docs preview so it is reaped whenever the worktree is removed — replacing the fragile [post-remove] port-kill hook that leaked ~83 zola processes.
2026-06-24 19:45:05 -07:00

179 lines
6.4 KiB
Rust

//! Integration tests for `wt step tether`.
//!
//! `tether` runs a command in its own process group and tears the whole group
//! down (a) when its worktree is removed and (b) when the command exits on its
//! own. The supervised snippet records its process-group id (the leader's pid,
//! since `tether` puts the child in a new group) and backgrounds a second
//! sleep, so the assertions prove the whole group dies, including the
//! reparented sidecar a parent-walk would miss.
#![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;
/// True while any process in group `pgid` still exists. `kill(-pgid, 0)` is the
/// portable group-liveness probe: `ESRCH` once the group is empty.
fn group_alive(pgid: i32) -> bool {
!matches!(kill(Pid::from_raw(-pgid), None), Err(Errno::ESRCH))
}
/// Poll `cond` with a generous cap and fast interval (per tests/CLAUDE.md:
/// long timeout for slow CI, fast polling so it returns immediately when the
/// event has happened).
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()
}
/// Spawn `wt step tether` supervising a snippet that records its pgid to
/// `pidfile`, backgrounds a sidecar sleep, then blocks. Returns the child
/// handle and the pgid once recorded.
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)
}
/// Both ways a worktree disappears tear the whole group down: outright
/// deletion (`rm -rf` / `git worktree remove`, `NOTE_DELETE`/`IN_DELETE_SELF`)
/// and rename into trash (worktrunk's own removal, `NOTE_RENAME`/
/// `IN_MOVE_SELF`).
#[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());
// Clean up before asserting so a failure can't leak the sleeps.
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"
);
}
}
/// When the supervised command exits on its own, the supervisor sweeps the
/// group (so the backgrounded sidecar dies too) and then exits.
#[rstest]
fn test_tether_kills_process_group_when_command_exits(repo: TestRepo) {
let pidfile = repo.home_path().join("tether-selfexit.pgid");
// Foreground `true` exits immediately; the sidecar `sleep 600` would
// outlive it without the group sweep.
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"
);
}
/// The global `-C <dir>` flag runs the tethered command in that directory —
/// the canonical way to start a server from a subdirectory (zola in `docs/`,
/// a dev server in `frontend/`) given the command runs directly with no shell
/// to `cd`. The reaper still watches the launch cwd, not `-C`.
#[rstest]
fn test_tether_runs_command_in_working_dir(repo: TestRepo) {
let subdir = repo.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let cwdfile = repo.home_path().join("tether-cwd.txt");
// The command writes its cwd and exits; the supervisor then sweeps the
// group and returns, so `status()` completes with nothing left running.
let status = repo
.wt_command()
.args([
"-C",
subdir.to_str().unwrap(),
"step",
"tether",
"--",
"sh",
"-c",
&format!("pwd -P > \"{}\"", cwdfile.display()),
])
.current_dir(repo.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run wt step tether -C");
assert!(
status.success(),
"tether should exit 0 on command self-exit"
);
let recorded =
std::fs::read_to_string(&cwdfile).expect("tethered command should record its cwd");
assert_eq!(
std::fs::canonicalize(recorded.trim()).unwrap(),
std::fs::canonicalize(&subdir).unwrap(),
"the tethered command must run in the -C directory"
);
}