From 61fd7cfec5f1e3658c85022cb31e00dc1775d476 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 8 Apr 2026 11:04:27 +0000 Subject: [PATCH] Lock in Linux hook stdin BrokenPipe coverage Latest main already contains the functional BrokenPipe tolerance in plugins::hooks::CommandWithStdin::output_with_stdin, but the only coverage for the original CI failure was the higher-level plugin hook test. Add a deterministic regression that exercises the exact low-level EPIPE path by spawning a hook child that closes stdin immediately while the parent writes an oversized payload. This keeps the real root cause explicit: Linux surfaced BrokenPipe from the parent's stdin write after the hook child closed fd 0 early. Missing execute bits were not the primary bug. Constraint: Keep the change surgical on top of latest main Rejected: Re-open the production code path | latest main already contains the runtime fix Rejected: Inflate HookRunner payloads in the regression | HOOK_* env injection hit ARG_MAX before the pipe path Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep BrokenPipe coverage near CommandWithStdin so future refactors do not regress the Linux EPIPE path Tested: cargo test -p plugins hooks::tests::collects_and_runs_hooks_from_enabled_plugins -- --exact (10x) Tested: cargo test -p plugins hooks::tests::output_with_stdin_tolerates_broken_pipe_when_child_closes_stdin_early -- --exact (10x) Tested: cargo test --workspace Not-tested: GitHub Actions rerun on the PR branch --- rust/crates/plugins/src/hooks.rs | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/rust/crates/plugins/src/hooks.rs b/rust/crates/plugins/src/hooks.rs index ff02c2a..3edafca 100644 --- a/rust/crates/plugins/src/hooks.rs +++ b/rust/crates/plugins/src/hooks.rs @@ -561,4 +561,43 @@ mod tests { ); } } + + #[test] + fn output_with_stdin_tolerates_broken_pipe_when_child_closes_stdin_early() { + // given: a hook that immediately closes stdin without consuming the + // JSON payload. Use an oversized payload so the parent keeps writing + // long enough for Linux to surface EPIPE on the old implementation. + let root = temp_dir("stdin-close"); + let script = root.join("close-stdin.sh"); + fs::create_dir_all(&root).expect("temp hook dir"); + fs::write( + &script, + "#!/bin/sh\nexec 0<&-\nprintf 'stdin closed early\\n'\nsleep 0.05\n", + ) + .expect("write stdin-closing hook"); + make_executable(&script); + + let mut child = super::shell_command(script.to_str().expect("utf8 path")); + child.stdin(std::process::Stdio::piped()); + child.stdout(std::process::Stdio::piped()); + child.stderr(std::process::Stdio::piped()); + let large_input = vec![b'x'; 2 * 1024 * 1024]; + + // when + let output = child + .output_with_stdin(&large_input) + .expect("broken pipe should be tolerated"); + + // then + assert!( + output.status.success(), + "child should still exit cleanly: {output:?}" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "stdin closed early" + ); + + let _ = fs::remove_dir_all(root); + } }