Skip to content

fix(claude-code hook): fail open on closed stdout instead of logging Broken pipe (WEB-4745)#122

Open
vigneshsubbiah16 wants to merge 1 commit into
mainfrom
web-4745-broken-pipe-hardening
Open

fix(claude-code hook): fail open on closed stdout instead of logging Broken pipe (WEB-4745)#122
vigneshsubbiah16 wants to merge 1 commit into
mainfrom
web-4745-broken-pipe-hardening

Conversation

@vigneshsubbiah16

@vigneshsubbiah16 vigneshsubbiah16 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

What

The Claude Code hook (claude-code/hooks/unbound.py) was flooding ~/.claude/hooks/error.log with Exception in main: [Errno 32] Broken pipe (~18 of ~25 lines on a dogfood machine over two weeks). This makes the one log we tell support to read useless for spotting the failures that matter (API timeouts) — directly undermining the self-diagnosing goal of the Coding Telemetry Reliability project.

Closes WEB-4745.

Root cause

Claude Code closes the hook's stdout as soon as it has the response it needs (or when it times the hook out). The hook's bare print(..., flush=True) calls then raise BrokenPipeError:

  1. Caught by except Exception in main() → logged as Exception in main: Broken pipe (noise).
  2. The handler then print()ed again on the same dead pipe → potential double fault.
  3. The interpreter's shutdown-time stdout flush re-hit the dead pipe → Exception ignored ... BrokenPipeError on stderr + non-zero exit.

A closed pipe is not an actionable error — the response is simply moot. The hook should fail open silently.

Changes

  • Add a pipe-safe _emit() helper and route every main() stdout write through it (BrokenPipeError/OSError → silent no-op).
  • Add an explicit except BrokenPipeError in main() so a dead pipe is neither logged nor re-printed.
  • Apply the standard CPython SIGPIPE recipe at the entry point (redirect stdout → devnull) so the shutdown flush is a no-op and the hook exits cleanly.
  • New test_hook_io.py: subprocess-level tests (outermost layer) asserting, on a closed stdout, clean exit 0 / no traceback / no Broken pipe log line, and that the normal path still emits valid JSON.

Test plan

  • python3 -m pytest claude-code/hooks/test_hook_io.py → 3 passed.
  • Full hook suite: 45 passed, 1 failed. The single failure (test_identity.py::...test_keys_limited_to_identity_fields) is pre-existing on main and unrelated to this change (build_account_identity returning device_serial/user_email).
  • Manual: feeding an event with stdout closed exits 0 with empty stderr and no error.log noise; with stdout open it emits valid PreToolUse JSON and fails open.

Scope note

This is the safe, behavior-preserving slice. The related security findings — API key in curl argv / leaked into error.log (WEB-4748, WEB-4734) — and the 20s synchronous-call stall (WEB-4746) are not included here; they change the HTTP mechanism / enforcement timing and should land separately with live-gateway validation.

🤖 Generated with Claude Code

Greptile Summary

This PR fixes BrokenPipeError noise that dominated ~/.claude/hooks/error.log when Claude Code closed the hook's stdout early. It introduces a _emit() helper that silently absorbs pipe errors on every write, adds an explicit except BrokenPipeError in main(), and applies the standard CPython SIGPIPE recipe at the entry point so the interpreter's shutdown flush is also a no-op.

  • _emit() helper (unbound.py): wraps every sys.stdout.write + flush in a (BrokenPipeError, OSError) guard, replacing all bare print() calls throughout main().
  • Entry-point SIGPIPE recipe: after main() returns, os.dup2(os.devnull, stdout_fd) is called in a finally block so the interpreter's own shutdown flush lands on /dev/null rather than a dead pipe.
  • test_hook_io.py: three new subprocess-level tests assert clean exit (rc=0), no traceback on stderr, no Broken pipe in error.log, and valid JSON emission on the normal path.

Confidence Score: 4/5

Safe to merge — the change is narrowly scoped to swallowing stdout pipe errors and adds no new logic to the hook's processing path.

The fix is correct and the tests cover the advertised scenarios. Two minor concerns: the except BrokenPipeError: pass block in main() wraps the entire processing body, so a pipe failure inside a subprocess called by process_pre_tool_use would be silently dropped rather than logged; and the test's proc.wait() / proc.stderr.read() ordering is susceptible to a deadlock if stderr output ever grows large. Neither is a current defect for this hook in practice.

Both changed files are small and self-contained. unbound.py's new except BrokenPipeError handler is worth a second look to confirm it cannot mask real subprocess pipe failures in process_pre_tool_use.

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Adds _emit() pipe-safe helper and replaces all bare print() calls; adds except BrokenPipeError in main() and applies CPython SIGPIPE recipe at entry point — correct approach, but the BrokenPipeError handler scope is wider than stdout alone.
claude-code/hooks/test_hook_io.py New subprocess-level test suite covering closed-stdout exit code, error.log silence, and normal JSON emission — good coverage, but proc.wait() before proc.stderr.read() is susceptible to a theoretical deadlock.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Claude Code spawns hook] --> B[main reads stdin]
    B --> C{Parse JSON event}
    C -- invalid --> D[_emit suppressOutput]
    C -- PreToolUse --> E[process_pre_tool_use]
    E --> F[_emit response]
    C -- SessionStart --> G[warm caches and dispatch]
    G --> H[_emit empty object]
    C -- other --> I[append_to_audit_log]
    I --> J[_emit suppressOutput]
    D --> K{stdout open?}
    F --> K
    H --> K
    J --> K
    K -- yes --> L[write and flush succeeds]
    K -- no --> M[BrokenPipeError swallowed silently]
    B -- BrokenPipeError --> N[except BrokenPipeError pass]
    B -- other Exception --> O[log_error and _emit fallback]
    L --> P[main returns]
    M --> P
    N --> P
    O --> P
    P --> Q[sys.stdout.flush in __main__]
    Q -- BrokenPipeError --> R[except BrokenPipeError pass]
    Q -- ok --> S[finally: dup2 stdout to devnull]
    R --> S
    S --> T[interpreter shutdown flush to devnull exit 0]
Loading

Reviews (1): Last reviewed commit: "fix(claude-code hook): fail open on clos..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…Broken pipe (WEB-4745)

Claude Code closes the hook's stdout as soon as it has the response it needs
(or when it times the hook out). The hook's bare print() calls then raised
BrokenPipeError, which dominated error.log with "Exception in main: Broken
pipe" — burying the failures we actually care about (API timeouts) — and the
except handler re-print()ed on the same dead pipe, risking a double fault.
Separately, the interpreter's shutdown-time stdout flush re-hit the dead pipe,
printing "Exception ignored ... BrokenPipeError" and exiting non-zero.

- Add pipe-safe _emit(); route all main() stdout writes through it.
- Catch BrokenPipeError in main() (no noisy log, no re-print).
- Apply the standard CPython SIGPIPE recipe at the entry point so the
  shutdown flush is a no-op and the hook exits cleanly.
- Add test_hook_io.py: subprocess-level tests asserting clean exit, no
  traceback, no Broken-pipe log line, and that the normal path still emits
  valid JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vigneshsubbiah16 vigneshsubbiah16 requested a review from a team June 10, 2026 03:14
Comment on lines +1596 to +1600
except BrokenPipeError:
# Claude Code closed our stdout before we finished — nothing to report
# and nothing to write. Swallow it so it doesn't become error.log noise
# (and so we don't re-raise by trying to write again). See WEB-4745.
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Overly-broad BrokenPipeError handler silently swallows non-stdout pipe failures

The except BrokenPipeError: pass wraps the entire main() body, not just the _emit() calls. If any processing code inside the try block — such as a subprocess pipe used in process_pre_tool_use (the PR description references a curl-based HTTP call in that path) — raises BrokenPipeError, the error is silently discarded with no log entry. The intent is clearly to ignore stdout pipe closure, but the handler is not scoped to stdout. A subprocess pipe failure mid-request would be treated identically to a closed stdout and leave no diagnostic trace in error.log.

Comment on lines +48 to +51
rc = proc.wait(timeout=30)
err = proc.stderr.read().decode()
out = b"" if close_stdout else proc.stdout.read()
proc.stderr.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Potential deadlock when reading stderr after proc.wait() with stdout closed

proc.wait() blocks until the subprocess exits. If the subprocess writes enough to stderr to fill the OS pipe buffer (~64 KB) before it has consumed all of stdin, both sides will block: the subprocess trying to write stderr, the test waiting for the subprocess to exit. In this hook the risk is low, but the safer pattern is proc.communicate() (with stdin pre-supplied) or draining stderr concurrently via a thread, rather than sequentially waiting then reading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant