use devflow_core::agent;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
fn devflow_bin() -> &'static str {
env!("CARGO_BIN_EXE_devflow")
}
fn wait_for(mut predicate: impl FnMut() -> bool, timeout_secs: u64, what: &str) {
let start = Instant::now();
while !predicate() {
assert!(
start.elapsed() < Duration::from_secs(timeout_secs),
"timed out after {timeout_secs}s waiting for: {what}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn nested_root_fixture() -> (tempfile::TempDir, std::path::PathBuf) {
let outer = tempfile::tempdir().unwrap();
let inner_root = outer.path().join("project-root");
std::fs::create_dir_all(&inner_root).unwrap();
(outer, inner_root)
}
fn spawn_monitor_wrapper_fixture(cwd: &Path) -> std::process::Child {
let child = std::process::Command::new("sh")
.arg("-c")
.arg("trap cleanup TERM INT; sleep 30")
.current_dir(cwd)
.spawn()
.expect("spawn monitor-wrapper-shaped fixture");
let pid = child.id();
assert!(
devflow_core::test_support::wait_for_exec_visibility(
pid,
"sh",
devflow_core::test_support::EXEC_VISIBILITY_WAIT,
devflow_core::test_support::EXEC_VISIBILITY_POLL,
),
"pid {pid}: exec visibility timed out before the fixture became discoverable"
);
child
}
#[test]
fn reap_clears_a_process_whose_root_was_deleted_which_devflow_stop_cannot_see() {
let (outer, root) = nested_root_fixture();
let mut child = spawn_monitor_wrapper_fixture(&root);
let pid = child.id();
wait_for(
|| agent::agent_running(pid),
5,
"the fixture to start running",
);
let start_time =
agent::process_start_time(pid).expect("must read the fixture's own recorded start time");
std::fs::remove_dir_all(&root).expect("delete the fixture's project root while it is alive");
assert!(!root.exists(), "the root must actually be gone");
assert!(
agent::agent_running(pid),
"the fixture must still be alive immediately after its root is deleted -- deleting a \
directory does not touch a process using it only as a historical cwd"
);
let stop_output = Command::new(devflow_bin())
.args(["stop", "--phase", "900", "--root"])
.arg(&root)
.output()
.expect("run devflow stop");
assert!(
!stop_output.status.success(),
"devflow stop must fail to act on a root that no longer exists on disk, not silently \
succeed against something else: stdout={} stderr={}",
String::from_utf8_lossy(&stop_output.stdout),
String::from_utf8_lossy(&stop_output.stderr)
);
assert!(
agent::agent_running(pid),
"devflow stop against a deleted root must not have touched the fixture"
);
let discovered = agent::discover_stray_devflow_processes();
let candidate = discovered
.iter()
.find(|p| p.pid == pid)
.expect("discovery must still find the fixture even though its root is gone");
assert_eq!(candidate.layer, agent::StrayLayer::MonitorWrapper);
assert!(
agent::is_same_process(candidate.pid, candidate.start_time),
"the freshly discovered candidate must match its own just-recorded start time"
);
assert_eq!(
candidate.start_time, start_time,
"discovery's recorded start time must agree with what we read directly"
);
let cleared = agent::terminate_and_verify(
candidate.pid,
agent::TERMINATE_VERIFY_WAIT,
agent::TERMINATE_VERIFY_POLL,
);
assert!(
cleared,
"the reaping path must clear a process whose root has been deleted -- exactly the \
condition file-based discovery structurally cannot see"
);
assert!(
!agent::agent_running(pid),
"the fixture must be verified dead after reaping, not merely assumed"
);
let _ = child.kill();
let _ = child.wait();
drop(outer);
}
#[test]
fn reap_clears_a_sigterm_ignoring_stray_with_a_deleted_root() {
let (outer, root) = nested_root_fixture();
let mut child = Command::new("sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.current_dir(&root)
.spawn()
.expect("spawn TERM-ignoring fixture");
let pid = child.id();
wait_for(
|| agent::agent_running(pid),
5,
"the fixture to start running",
);
std::thread::sleep(Duration::from_millis(100));
std::fs::remove_dir_all(&root).expect("delete the fixture's project root while it is alive");
let start = Instant::now();
let cleared = agent::terminate_and_verify(
pid,
agent::TERMINATE_VERIFY_WAIT,
agent::TERMINATE_VERIFY_POLL,
);
let elapsed = start.elapsed();
assert!(
cleared,
"a TERM-ignoring stray with a deleted root must still be cleared via SIGKILL escalation"
);
assert!(
!agent::agent_running(pid),
"the fixture must be verified dead, not merely signalled"
);
assert!(
elapsed < Duration::from_secs(5),
"escalation must complete within the bounded wait, took {elapsed:?}"
);
let _ = child.kill();
let _ = child.wait();
drop(outer);
}