From c47423677c6be9756851b3d4f20517ceecd1c52e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 21:35:08 -0700 Subject: [PATCH 1/3] test(acpmux): a gated fake agent must exit with its daemon The FAKE_START_GATE loop polls for its gate file and never reads stdin, so a test that stops its daemon before opening the gate leaves the agent (in its own process group) polling forever: 257 were left on huge-blue (2026-10-21). Red: the agent outlives its closed stdin. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bf1-fdac-78a0-ac2e-a3aef81402c7&host=cosmic-gray --- .../crates/acpmux/tests/person_auto_start.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs index 774115c80d01..a03964dae533 100644 --- a/cmux-tui/crates/acpmux/tests/person_auto_start.rs +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -251,6 +251,13 @@ impl Drop for Daemon { fn drop(&mut self) { // SAFETY: the pid of the child this test spawned and still owns. unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; + // A daemon that does not stop in time is killed, so a failed test + // never hangs here holding its agents' stdin open. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while matches!(self.child.try_wait(), Ok(None)) && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.child.kill(); let _ = self.child.wait(); let _ = std::fs::remove_dir_all(&self.dir); } @@ -689,6 +696,62 @@ async fn auto_start_a_request_during_the_start_never_rebinds_the_approval() { assert_eq!(current_mode(&s["result"]), "bypass", "{s}"); } +/// The gated harness never outlives the daemon holding its stdin: a test that +/// fails before it opens the gate leaves no agent polling for it (257 were +/// left on huge-blue, 2026-10-21). What the daemon sent before the gate opens +/// is still read after it. +#[test] +fn auto_start_a_gated_agent_exits_with_its_daemon_and_keeps_what_came_first() { + use std::io::Write; + let dir = std::env::temp_dir().join(format!("aas-gate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gate = dir.join("gate"); + let spawn = || { + std::process::Command::new("python3") + .arg(FAKE) + .env("FAKE_START_GATE", &gate) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap() + }; + let exited = |child: &mut std::process::Child| { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + if let Ok(Some(_)) = child.try_wait() { + return true; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + }; + + let mut waiting = spawn(); + std::thread::sleep(Duration::from_millis(300)); + drop(waiting.stdin.take()); + assert!(exited(&mut waiting), "a gated agent outlived the stdin of its daemon"); + + let mut early = spawn(); + let mut stdin = early.stdin.take().unwrap(); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": 1}}); + writeln!(stdin, "{init}").unwrap(); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&gate, "").unwrap(); + let mut line = String::new(); + BufReader::new(early.stdout.take().unwrap()).read_line(&mut line).unwrap(); + drop(stdin); + assert!(exited(&mut early), "the agent did not exit at EOF after its gate"); + let _ = std::fs::remove_dir_all(&dir); + let reply: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}")); + assert_eq!(reply["id"], 1, "{reply}"); +} + /// The caller token and its socket are acpmux's own: a harness profile's or /// a preset's env that names them is dropped, so an agent never runs with a /// token or socket its config chose (`hub/person.rs` (a)). From 5f7ea6ff970daa89f219baecd43f97ca964b866e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 21:35:09 -0700 Subject: [PATCH 2/3] fix(acpmux tests): a gated fake agent exits when its stdin closes While it waits at FAKE_START_GATE it now selects on stdin: EOF (the daemon that started it is gone, even after a SIGKILL) ends it, and lines that arrive first are kept for after the gate. person_auto_start's Daemon drop kills a daemon that does not stop within 10 s, so a hung shutdown never holds its agents' stdin open. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bf1-fdac-78a0-ac2e-a3aef81402c7&host=cosmic-gray --- cmux-tui/crates/acpmux/tests/fake_agent.py | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index 9f22044728c0..ac318779366c 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -727,6 +727,15 @@ def late(): ("claude-haiku-5-5", "Haiku 5.5"), ("claude-fable-5-1", "Fable 5.1")] +def stdin_lines(early): + """stdin's lines, starting with the bytes read while waiting at the start gate.""" + if early: + if not early.endswith(b"\n"): + early += sys.stdin.buffer.readline() + yield from early.decode().splitlines(keepends=True) + yield from sys.stdin + + def main(): global LAST_MCP_SERVERS # `--fake-login` (the terminal sign-in): sign in and exit. @@ -738,13 +747,21 @@ def main(): import signal signal.signal(signal.SIGTERM, signal.SIG_IGN) # FAKE_START_GATE=: start (read stdin) only once that file exists: a slow agent start. + # It still watches stdin while it waits: once the daemon that started it is gone (EOF), it exits, so a + # test that never opens the gate leaves no agent behind (257 of them polled forever on huge-blue, 2026-10-21). + # What arrives meanwhile is kept for after the gate. gate = os.environ.get("FAKE_START_GATE") + early = b"" if gate: - import time + import select while not os.path.exists(gate): - time.sleep(0.02) + if select.select([sys.stdin.fileno()], [], [], 0.02)[0]: + chunk = os.read(sys.stdin.fileno(), 65536) + if not chunk: + return + early += chunk sessions = 0 - for line in sys.stdin: + for line in stdin_lines(early): line = line.strip() if not line: continue From 03eda2d8b7ccc2e9a97da159f37083c2a4114085 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 21:38:38 -0700 Subject: [PATCH 3/3] fix(acpmux tests): read pre-gate lines the way stdin does Review: splitlines() also breaks at U+2028/U+2029, which serde_json writes unescaped, and strict UTF-8 differs from stdin's own decoding. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15bf1-fdac-78a0-ac2e-a3aef81402c7&host=cosmic-gray --- cmux-tui/crates/acpmux/tests/fake_agent.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index ac318779366c..f032b3c31410 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -732,7 +732,9 @@ def stdin_lines(early): if early: if not early.endswith(b"\n"): early += sys.stdin.buffer.readline() - yield from early.decode().splitlines(keepends=True) + # Split and decoded as stdin is: only at newlines (serde_json leaves U+2028 unescaped). + import io + yield from io.TextIOWrapper(io.BytesIO(early), encoding=sys.stdin.encoding, errors=sys.stdin.errors) yield from sys.stdin