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 PalhasandClaude Opus 5 9d777e2a22
ci / web (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 1m51s
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m31s
fix(maestro): snapshot /proc once per pass and check the whole group
The subtree search returned early whenever it found *any* group member,
so an in-tree stage that was busy or sleeping on a timer masked a
reparented member genuinely parked on the tty.

Checking every member instead would have meant a /proc scan per session
per tick whenever nothing was parked — the common case. ProcGroups
snapshots /proc once per activity pass and indexes by process group, so
the cost is one walk however many sessions the daemon owns, and the
subtree walk disappears: the index is a superset of it.

Also fixes the background-helper test, which had stopped discriminating.
It polled until awaiting_input was true and caught a moment before the
helper spawned, so it passed against a deepest-descendant walk too. It now
waits for the helper to exist first. Both regression tests were re-checked
by patching each old implementation back in.

Reported in review on #51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:23:17 +01:00

197 lines
7.2 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"
);
assert!(
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
"a surviving pipeline member still holds the tty"
);
}