diff --git a/crates/xy/Cargo.toml b/crates/xy/Cargo.toml index 20c0fee..1d7deef 100644 --- a/crates/xy/Cargo.toml +++ b/crates/xy/Cargo.toml @@ -16,6 +16,10 @@ path = "src/bin/xy_test_sleep_server.rs" name = "xy-test-exit-failure" path = "src/bin/xy_test_exit_failure.rs" +[[bin]] +name = "xy-test-stdin-reader" +path = "src/bin/xy_test_stdin_reader.rs" + [dependencies] xy-protocol.workspace = true xy-supervisor.workspace = true diff --git a/crates/xy/src/bin/xy_test_stdin_reader.rs b/crates/xy/src/bin/xy_test_stdin_reader.rs new file mode 100644 index 0000000..97cbf58 --- /dev/null +++ b/crates/xy/src/bin/xy_test_stdin_reader.rs @@ -0,0 +1,21 @@ +/// Stands in for a stdio-first MCP server: it comes up, announces itself, then +/// blocks reading stdin and exits the moment it sees EOF. +fn main() { + use std::io::{BufRead, Write}; + + println!("ready"); + std::io::stdout().flush().ok(); + + let mut line = String::new(); + match std::io::stdin().lock().read_line(&mut line) { + Ok(0) => { + eprintln!("stdin closed (EOF), shutting down"); + std::process::exit(0); + } + Ok(_) => std::process::exit(1), + Err(err) => { + eprintln!("stdin read error: {err}"); + std::process::exit(2); + } + } +} diff --git a/crates/xy/tests/common/mod.rs b/crates/xy/tests/common/mod.rs index 90515a1..5797c33 100644 --- a/crates/xy/tests/common/mod.rs +++ b/crates/xy/tests/common/mod.rs @@ -37,8 +37,20 @@ impl Harness { } pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) { + self.write_server_with(name, command, port, restart_policy, ""); + } + + /// `extra` is appended verbatim as additional top-level KDL. + pub fn write_server_with( + &self, + name: &str, + command: &str, + port: u16, + restart_policy: &str, + extra: &str, + ) { let body = format!( - "command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n" + "command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n{extra}" ); std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap(); } @@ -49,6 +61,7 @@ impl Harness { .env("XDG_CONFIG_HOME", self.tmp.path().join("config")) .env("XDG_STATE_HOME", self.tmp.path().join("state")) .env("XDG_RUNTIME_DIR", self.tmp.path().join("run")) + .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) .kill_on_drop(true) @@ -99,6 +112,9 @@ pub fn sleep_server_bin() -> PathBuf { pub fn exit_failure_bin() -> PathBuf { artifact("xy-test-exit-failure") } +pub fn stdin_reader_bin() -> PathBuf { + artifact("xy-test-stdin-reader") +} fn artifact(name: &str) -> PathBuf { let mut p = std::env::current_exe().unwrap(); diff --git a/crates/xy/tests/stdin_mode.rs b/crates/xy/tests/stdin_mode.rs new file mode 100644 index 0000000..24bb4b6 --- /dev/null +++ b/crates/xy/tests/stdin_mode.rs @@ -0,0 +1,68 @@ +mod common; +use common::*; +use std::time::Duration; +use xy_protocol::ServerState; + +async fn state_of(h: &Harness, name: &str) -> ServerState { + h.list_rpc() + .await + .into_iter() + .find(|s| s.name == name) + .unwrap_or_else(|| panic!("no server named {name}")) + .state +} + +async fn settle(h: &Harness, name: &str) -> ServerState { + let mut last = ServerState::Stopped; + for _ in 0..40 { + last = state_of(h, name).await; + if last == ServerState::Running { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + last +} + +#[tokio::test] +async fn keep_open_lets_a_stdin_reader_stay_running() { + let xy = xy_bin(); + let reader = stdin_reader_bin(); + let mut h = Harness::new(); + h.write_server_with( + "reader", + reader.to_str().unwrap(), + 19_010, + "never", + "stdin \"keep-open\"\n", + ); + h.start_daemon(&xy).await; + + assert_eq!(settle(&h, "reader").await, ServerState::Running); + + // Still up a beat later: it is blocked on read, not merely slow to die. + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!(state_of(&h, "reader").await, ServerState::Running); +} + +/// Characterizes the reported bug: the daemon's stdin is `/dev/null` under +/// launchd, so by default a stdio-first server sees EOF and exits at once. +#[tokio::test] +async fn without_keep_open_a_stdin_reader_exits_on_eof() { + let xy = xy_bin(); + let reader = stdin_reader_bin(); + let mut h = Harness::new(); + h.write_server("reader", reader.to_str().unwrap(), 19_011, "never"); + h.start_daemon(&xy).await; + + let mut last = ServerState::Running; + for _ in 0..40 { + last = state_of(&h, "reader").await; + if last == ServerState::Stopped { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert_eq!(last, ServerState::Stopped); +}