//! Activity detection against real processes on a real tty. //! //! The review noted this pipeline had no end-to-end coverage — `activity::decide` //! was unit-tested, but nothing exercised the `/proc` inspection that feeds it. //! These use a real tmux pane because that is the only way to get a controlling //! terminal with a genuine foreground process group. mod common; use std::time::Duration; use common::TmuxFixture; use maestro_daemon::proc::{awaiting_input, ProcGroups}; use tempfile::TempDir; use uuid::Uuid; /// Poll `awaiting_input` until it reports `want`, so we don't race the shell /// reaching its blocking read. Reports what it last saw — the useful half of /// the failure. fn wait_for_awaiting(fx: &TmuxFixture, id: Uuid, want: bool, label: &str) { let deadline = std::time::Instant::now() + Duration::from_secs(5); let mut last = None; while std::time::Instant::now() < deadline { if let Ok(Some(pane)) = fx.tmux.pane_info(id) { // Fresh each poll: the tree is still settling. let got = awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()); if got == want { return; } last = Some(got); } std::thread::sleep(Duration::from_millis(25)); } panic!("{label}: wanted awaiting_input={want}, last saw {last:?}"); } #[test] fn an_idle_shell_is_awaiting_input() { let fx = TmuxFixture::new(); let cwd = TempDir::new().unwrap(); let id = Uuid::new_v4(); fx.tmux .new_session(id, cwd.path(), "bash --noprofile --norc") .expect("new_session"); wait_for_awaiting(&fx, id, true, "an idle shell sits in read()"); } /// The case the old heuristic got wrong. It walked `children` to the deepest /// descendant, so it landed on the background `sleep` — which is not on the /// terminal — and reported the session as *not* awaiting input, while the /// foreground process was blocked on `read`. #[test] fn a_foreground_reader_with_a_background_helper_is_awaiting_input() { let fx = TmuxFixture::new(); let cwd = TempDir::new().unwrap(); let script = cwd.path().join("agentish.sh"); std::fs::write(&script, "#!/bin/bash\nsleep 300 &\nread -r line\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); } let id = Uuid::new_v4(); fx.tmux .new_session( id, cwd.path(), &format!("bash --noprofile --norc {}", script.display()), ) .expect("new_session"); // Wait for the helper itself. Asserting before it exists would pass // against a deepest-descendant walk too, because until then the newest // process in the tree *is* the one blocked on read. let pane = fx.tmux.pane_info(id).expect("pane_info").expect("pane"); wait_until( || subtree_has_comm(pane.pane_pid, "sleep"), "the background helper to be spawned", ); wait_for_awaiting( &fx, id, true, "a foreground reader must win over a newer background child", ); } /// A pure timer sleep also parks in 'S' with the terminal inherited; it must /// read as idle, not as waiting on the user. #[test] fn a_foreground_sleep_is_not_awaiting_input() { let fx = TmuxFixture::new(); let cwd = TempDir::new().unwrap(); let id = Uuid::new_v4(); fx.tmux .new_session(id, cwd.path(), "sleep 300") .expect("new_session"); wait_for_awaiting(&fx, id, false, "a timer sleep is not user input"); } /// Whether any process under `root` (inclusive) has this `comm`. fn subtree_has_comm(root: i32, comm: &str) -> bool { let mut queue = vec![root]; while let Some(pid) = queue.pop() { if std::fs::read_to_string(format!("/proc/{pid}/comm")).is_ok_and(|c| c.trim() == comm) { return true; } queue.extend( std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")) .map(|s| { s.split_whitespace() .filter_map(|x| x.parse::().ok()) .collect::>() }) .unwrap_or_default(), ); } false } /// Block until `cond` holds, so a test asserts on the state it means to /// exercise rather than on whatever the tree looked like first. fn wait_until(cond: impl Fn() -> bool, what: &str) { let deadline = std::time::Instant::now() + Duration::from_secs(5); while std::time::Instant::now() < deadline { if cond() { return; } std::thread::sleep(Duration::from_millis(20)); } panic!("timed out waiting for {what}"); } /// Read `pgrp` and `tpgid` for a pid straight from `/proc`, so the test can /// wait for the exact condition it means to exercise rather than a proxy. fn pgrp_and_tpgid(pid: i32) -> Option<(i32, i32)> { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let close = stat.rfind(')')?; let f: Vec<&str> = stat[close + 1..].split_whitespace().collect(); Some((f.get(2)?.parse().ok()?, f.get(5)?.parse().ok()?)) } /// `tpgid` names a process *group*, and its leader can exit while another /// member still holds the terminal — a foreground pipeline whose first stage /// finished. Reading `/proc/` directly then finds nothing and reports /// idle, so the group has to be searched for a live member. /// /// Needs an interactive shell: job control is what puts a pipeline in its own /// process group, and that is how the daemon spawns commands /// (`$SHELL -lic ''`). #[test] fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() { let fx = TmuxFixture::new(); let cwd = TempDir::new().unwrap(); let id = Uuid::new_v4(); fx.tmux .new_session(id, cwd.path(), "bash --noprofile --norc -i") .expect("new_session"); wait_for_awaiting(&fx, id, true, "the shell must settle first"); // `true` leads the group and exits immediately; the right-hand stage // survives, blocked reading the terminal. fx.tmux .send_keys(id, b"true | { read -r line < /dev/tty; }\n") .expect("send_keys"); // Wait for the state this test exists for: the foreground group is no // longer the shell's own, and its leader is gone. Asserting before that // would pass against a leader-only lookup, because the shell is still // foreground and alive. let pane = fx.tmux.pane_info(id).expect("pane_info").expect("pane"); let deadline = std::time::Instant::now() + Duration::from_secs(5); let mut reached = false; while std::time::Instant::now() < deadline { if let Some((pgrp, tpgid)) = pgrp_and_tpgid(pane.pane_pid) { if tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none() { reached = true; break; } } std::thread::sleep(Duration::from_millis(20)); } assert!( reached, "never observed a foreground group whose leader had exited" ); // The leader can be gone while the surviving stage is still setting up: // `true` exits as soon as both stages are forked, before the reader has // opened the tty and parked. Poll for the answer, but only accept it // while the state this test exists for still holds — otherwise a shell // that regained the terminal would satisfy it vacuously. let deadline = std::time::Instant::now() + Duration::from_secs(5); let mut holds = false; while std::time::Instant::now() < deadline { let groups = ProcGroups::snapshot(); let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| { tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none() }); if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) { holds = true; break; } std::thread::sleep(Duration::from_millis(20)); } assert!(holds, "a surviving pipeline member still holds the tty"); }