From 076324c4700bf5069a7382b4dbd4ea846e47a761 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 12:49:12 +0000 Subject: [PATCH] Fix pipe buffer deadlock in quality gate test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_command_with_timeout piped stdout/stderr but only read them after the child exited. When test output exceeded the 64KB OS pipe buffer, the child blocked on write() while the parent blocked on waitpid() — a permanent deadlock that caused every merge pipeline to hang. Drain both pipes in background threads so the buffers never fill. Co-Authored-By: Claude Opus 4.6 --- server/src/agents/gates.rs | 39 +++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/server/src/agents/gates.rs b/server/src/agents/gates.rs index 26858ec..53436dd 100644 --- a/server/src/agents/gates.rs +++ b/server/src/agents/gates.rs @@ -93,6 +93,10 @@ pub(crate) fn run_project_tests(path: &Path) -> Result<(bool, String), String> { /// Run a command with a timeout. Returns `(success, combined_output)`. /// Kills the child process if it exceeds `TEST_TIMEOUT`. +/// +/// Stdout and stderr are drained in background threads to avoid a pipe-buffer +/// deadlock: if the child fills the 64 KB OS pipe buffer while the parent +/// blocks on `waitpid`, neither side can make progress. fn run_command_with_timeout( program: impl AsRef, args: &[&str], @@ -106,19 +110,32 @@ fn run_command_with_timeout( .spawn() .map_err(|e| format!("Failed to spawn command: {e}"))?; + // Drain stdout/stderr in background threads so the pipe buffers never fill. + let stdout_handle = child.stdout.take().map(|r| { + std::thread::spawn(move || { + let mut s = String::new(); + let mut r = r; + std::io::Read::read_to_string(&mut r, &mut s).ok(); + s + }) + }); + let stderr_handle = child.stderr.take().map(|r| { + std::thread::spawn(move || { + let mut s = String::new(); + let mut r = r; + std::io::Read::read_to_string(&mut r, &mut s).ok(); + s + }) + }); + match child.wait_timeout(TEST_TIMEOUT) { Ok(Some(status)) => { - // Process exited within the timeout — collect output. - let stdout = child.stdout.take().map(|mut r| { - let mut s = String::new(); - std::io::Read::read_to_string(&mut r, &mut s).ok(); - s - }).unwrap_or_default(); - let stderr = child.stderr.take().map(|mut r| { - let mut s = String::new(); - std::io::Read::read_to_string(&mut r, &mut s).ok(); - s - }).unwrap_or_default(); + let stdout = stdout_handle + .and_then(|h| h.join().ok()) + .unwrap_or_default(); + let stderr = stderr_handle + .and_then(|h| h.join().ok()) + .unwrap_or_default(); Ok((status.success(), format!("{stdout}{stderr}"))) } Ok(None) => {