The pipeline leader exits as soon as both stages are forked, which can beat the surviving stage opening the tty and parking on read — the one- shot assertion then ran against a group with no parked member yet. Poll for the answer, accepting it only while the foreground group is still leaderless so a shell that regained the tty cannot satisfy it. Fixes #113
212 lines
8.0 KiB
Rust
212 lines
8.0 KiB
Rust
//! 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::<i32>().ok())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.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/<tpgid>` 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 '<cmd>'`).
|
|
#[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");
|
|
}
|