fix: capture test output with background pipe draining instead of Stdio::inherit

Stdio::inherit sent test output to server stdout, making it invisible
to agents calling run_tests via MCP. Switch back to Stdio::piped with
background drain threads (same pattern as gates.rs) to capture output
without the pipe deadlock that caused the original switch to inherit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-13 16:17:06 +00:00
parent 7977b7c5f8
commit bd04c6acd7
+47 -9
View File
@@ -399,12 +399,14 @@ pub(super) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
} }
} }
// Spawn the test process. // Spawn the test process with piped stdout/stderr so we can capture output.
let child = std::process::Command::new("bash") // Pipes are drained in background threads to prevent deadlock when the
// child fills the 64KB OS pipe buffer.
let mut child = std::process::Command::new("bash")
.arg(&script_path) .arg(&script_path)
.current_dir(&working_dir) .current_dir(&working_dir)
.stdout(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit()) .stderr(std::process::Stdio::piped())
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn test script: {e}"))?; .map_err(|e| format!("Failed to spawn test script: {e}"))?;
@@ -415,6 +417,22 @@ pub(super) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
pid pid
); );
// Drain stdout/stderr in background threads so pipe buffers never fill.
let mut stdout_handle = child.stdout.take().map(|mut r| {
std::thread::spawn(move || {
let mut s = String::new();
std::io::Read::read_to_string(&mut r, &mut s).ok();
s
})
});
let mut stderr_handle = child.stderr.take().map(|mut r| {
std::thread::spawn(move || {
let mut s = String::new();
std::io::Read::read_to_string(&mut r, &mut s).ok();
s
})
});
// Store the child so it can be cleaned up if the server restarts. // Store the child so it can be cleaned up if the server restarts.
{ {
let mut jobs = ctx.test_jobs.lock().map_err(|e| e.to_string())?; let mut jobs = ctx.test_jobs.lock().map_err(|e| e.to_string())?;
@@ -442,16 +460,36 @@ pub(super) async fn tool_run_tests(args: &Value, ctx: &AppContext) -> Result<Str
if let Some(child) = job.child.as_mut() { if let Some(child) = job.child.as_mut() {
match child.try_wait() { match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
// Done — collect results. // Done — join drain threads and collect output.
let result = collect_child_result(child, status); jobs.remove(&working_dir);
let stdout = stdout_handle
.take()
.and_then(|h| h.join().ok())
.unwrap_or_default();
let stderr = stderr_handle
.take()
.and_then(|h| h.join().ok())
.unwrap_or_default();
let combined = format!("{stdout}{stderr}");
let (tests_passed, tests_failed) = parse_test_counts(&combined);
let truncated = truncate_output(&combined, MAX_OUTPUT_LINES);
let passed = status.success();
let exit_code = status.code().unwrap_or(-1);
crate::slog!( crate::slog!(
"[run_tests] Test job for {} finished (pid {}, passed={})", "[run_tests] Test job for {} finished (pid {}, passed={})",
working_dir.display(), working_dir.display(),
pid, pid,
result.passed passed
); );
jobs.remove(&working_dir); return serde_json::to_string_pretty(&json!({
return format_test_result(&result); "passed": passed,
"exit_code": exit_code,
"timed_out": false,
"tests_passed": tests_passed,
"tests_failed": tests_failed,
"output": truncated,
}))
.map_err(|e| format!("Serialization error: {e}"));
} }
Ok(None) => { Ok(None) => {
// Still running — check timeout. // Still running — check timeout.