Bug[Hooks]: stdin write happens outside the per-hook timeout - a hook that ignores stdin can hang the turn forever, and a fast-exiting hook is wrongly marked failed

Open 💬 1 comment Opened Jun 11, 2026 by parveshsaini

What issue are you seeing?

In the hooks engine, the payload write to a hook's stdin happens before the per-hook timeout is set up, so it isn't covered by timeout_sec. This causes two distinct failures in run_command (codex-rs/hooks/src/engine/command_runner.rs):

  1. A stdin-ignoring hook hangs the turn indefinitely — timeout_sec never fires.

run_command does stdin.write_all(input_json.as_bytes()).await and only afterwards wraps child.wait_with_output() in timeout(...). If a configured hook command doesn't read stdin (very common for simple notification hooks) and the event payload is larger than the OS pipe buffer (~64 KiB — PreToolUse/PostToolUse payloads include full tool inputs/outputs), write_all blocks on the full pipe and the handler's timeout_sec clock never starts. execute_handlers (dispatcher.rs) awaits run_command with no outer timeout, so the turn blocks forever.

  1. A hook that exits successfully without draining stdin is reported as failed.

If the hook exits quickly (e.g. exit 0) after reading only part of stdin, the remaining write_all hits EPIPE/BrokenPipe. Because the write is awaited as a hard error, run_command returns exit_code: None and error: Some("failed to write hook stdin: ..."), marking a successful hook as failed.

Observed output from a focused test against the real run_command (see repro below):

thread '...fast_exiting_hook_ignoring_stdin_is_not_marked_failed' panicked at command_runner.rs:
assertion `left == right` failed: hook exited 0 but run_command reported error=Some("failed to write hook stdin: Broken pipe (os error 32)")
  left: None
 right: Some(0)

thread '...stdin_write_must_not_bypass_handler_timeout' panicked at command_runner.rs:
run_command did not honor handler timeout_sec=1s; still blocked after 5.000735024s (stdin write bypassed the timeout)

What steps can reproduce the bug?

Real-world shape: configure a hook whose command ignores stdin (e.g. a notification hook that just runs notify-send/logs and never reads its input), set a small timeout_sec, then trigger an event whose payload exceeds the pipe buffer (a PreToolUse/PostToolUse with a large tool input/output). The turn hangs and the timeout_sec never fires. A hook configured as a fast exit 0 that doesn't drain stdin instead shows up as a failed hook (failed to write hook stdin: Broken pipe).

Deterministic repro (two #[tokio::test]s exercising the unmodified run_command; both currently fail):

// hook that never reads stdin + payload larger than the ~64 KiB pipe buffer:
// run_command stays blocked past its own timeout_sec because write_all
// happens before timeout(...) is set up.
#[tokio::test]
async fn stdin_write_must_not_bypass_handler_timeout() {
    let shell = CommandShell { program: String::new(), args: Vec::new() };
    let handler = make_handler(/*command*/ "sleep 30", /*timeout_sec*/ 1);
    let payload = "x".repeat(1_000_000);

    let outer = tokio::time::timeout(
        std::time::Duration::from_secs(5),
        run_command(&shell, &handler, &payload, std::path::Path::new(".")),
    )
    .await;

    // Correct behavior: returns within ~1s with a "hook timed out after 1s" error.
    let result = outer.expect("run_command must honor handler timeout_sec");
    assert!(result.error.as_deref().unwrap_or_default().contains("timed out"));
}

// hook that exits 0 after reading only part of stdin:
// run_command reports it as failed because the leftover write hits EPIPE.
#[tokio::test]
async fn fast_exiting_hook_ignoring_stdin_is_not_marked_failed() {
    let shell = CommandShell { program: String::new(), args: Vec::new() };
    let handler = make_handler(/*command*/ "head -c 16 >/dev/null; exit 0", /*timeout_sec*/ 5);
    let payload = "x".repeat(1_000_000);

    let result = run_command(&shell, &handler, &payload, std::path::Path::new(".")).await;

    assert_eq!(result.exit_code, Some(0)); // currently None
    assert!(result.error.is_none());       // currently Some("failed to write hook stdin: Broken pipe ...")
}

(make_handler is the same helper used by the existing dispatcher.rs tests.)

What is the expected behavior?

  • The stdin write should be covered by the same timeout_sec as the rest of the hook run, so a hook that never drains stdin is killed at timeout_sec (error: "hook timed out after Ns") instead of hanging the turn forever.
  • A hook that exits successfully (exit 0) without reading all of stdin should be reported as successful — a BrokenPipe on the stdin write is expected in that case and shouldn't override a clean exit.

Additional information

  • Affected symbol: run_command in codex-rs/hooks/src/engine/command_runner.rs (the stdin.write_all(...).await that precedes the timeout(child.wait_with_output())); the missing outer timeout is in execute_handlers in codex-rs/hooks/src/engine/dispatcher.rs.
  • Suggested direction (not a patch): write stdin concurrently with wait_with_output() inside the timed section (e.g. drive the write and the wait together under one timeout(...)), and treat BrokenPipe/EPIPE from the stdin write as non-fatal (let the child's exit status decide success/failure). This keeps the documented timeout_sec contract for every hook regardless of whether it reads stdin.
I'd like to pick this up 🚀

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗