test(stdin): pin keep-open through the daemon
Covers the parser -> supervisor -> spawn wiring the unit tests miss, and characterizes the reported symptom: without `keep-open` the reader goes Stopped, with it the server stays Running. The harness now gives the daemon /dev/null on stdin. That is what launchd does, and without it these tests prove nothing — the runner's own stdin blocks on read, so an unfixed child looks identical to a fixed one. Refs #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
This commit is contained in:
@@ -16,6 +16,10 @@ path = "src/bin/xy_test_sleep_server.rs"
|
|||||||
name = "xy-test-exit-failure"
|
name = "xy-test-exit-failure"
|
||||||
path = "src/bin/xy_test_exit_failure.rs"
|
path = "src/bin/xy_test_exit_failure.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xy-test-stdin-reader"
|
||||||
|
path = "src/bin/xy_test_stdin_reader.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
xy-protocol.workspace = true
|
xy-protocol.workspace = true
|
||||||
xy-supervisor.workspace = true
|
xy-supervisor.workspace = true
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,20 @@ impl Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
|
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!(
|
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();
|
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_CONFIG_HOME", self.tmp.path().join("config"))
|
||||||
.env("XDG_STATE_HOME", self.tmp.path().join("state"))
|
.env("XDG_STATE_HOME", self.tmp.path().join("state"))
|
||||||
.env("XDG_RUNTIME_DIR", self.tmp.path().join("run"))
|
.env("XDG_RUNTIME_DIR", self.tmp.path().join("run"))
|
||||||
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
@@ -99,6 +112,9 @@ pub fn sleep_server_bin() -> PathBuf {
|
|||||||
pub fn exit_failure_bin() -> PathBuf {
|
pub fn exit_failure_bin() -> PathBuf {
|
||||||
artifact("xy-test-exit-failure")
|
artifact("xy-test-exit-failure")
|
||||||
}
|
}
|
||||||
|
pub fn stdin_reader_bin() -> PathBuf {
|
||||||
|
artifact("xy-test-stdin-reader")
|
||||||
|
}
|
||||||
|
|
||||||
fn artifact(name: &str) -> PathBuf {
|
fn artifact(name: &str) -> PathBuf {
|
||||||
let mut p = std::env::current_exe().unwrap();
|
let mut p = std::env::current_exe().unwrap();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user