This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/tests/activity.rs
T
Miguel Palhas f689c916bf fix(maestro): poll awaiting-input until leaderless state holds
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
2026-08-24 22:50:02 +01:00

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");
}