Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions claude-code/hooks/test_hook_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path

HOOK = Path(__file__).resolve().parent / "unbound.py"


class TestHookBrokenPipe(unittest.TestCase):
"""The hook must fail open when Claude Code closes its stdout early.

Claude closes the hook's pipe as soon as it has the response it needs (or
when it times the hook out). Writing to that closed pipe used to raise
BrokenPipeError, which (a) flooded error.log with "Exception in main:
Broken pipe" and (b) made the interpreter print "Exception ignored ...
BrokenPipeError" and exit non-zero at shutdown. See WEB-4745.

These run the real hook as a subprocess so they exercise the actual
stdout/stderr/exit-code behavior Claude Code sees.
"""

EVENT = json.dumps({
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "echo hi"},
"session_id": "test-broken-pipe",
}).encode()

def _run(self, close_stdout):
proc = subprocess.Popen(
[sys.executable, str(HOOK)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
# Point error.log at a throwaway dir so the test never touches the
# developer's real ~/.claude/hooks/error.log.
env={**os.environ, "HOME": str(Path(self._tmp))},
)
if close_stdout:
proc.stdout.close()
try:
proc.stdin.write(self.EVENT)
proc.stdin.close()
except BrokenPipeError:
pass
rc = proc.wait(timeout=30)
err = proc.stderr.read().decode()
out = b"" if close_stdout else proc.stdout.read()
proc.stderr.close()
Comment on lines +48 to +51

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.

return rc, out, err

def setUp(self):
import tempfile
self._tmpdir = tempfile.TemporaryDirectory()
self._tmp = self._tmpdir.name
self.addCleanup(self._tmpdir.cleanup)

def test_closed_stdout_exits_cleanly_with_no_traceback(self):
rc, _out, err = self._run(close_stdout=True)
self.assertEqual(rc, 0, f"expected clean exit on closed pipe, got {rc}")
self.assertNotIn("BrokenPipeError", err)
self.assertNotIn("Traceback", err)

def test_closed_stdout_does_not_log_broken_pipe(self):
self._run(close_stdout=True)
log = Path(self._tmp) / ".claude" / "hooks" / "error.log"
contents = log.read_text() if log.exists() else ""
self.assertNotIn("Broken pipe", contents)

def test_open_stdout_still_emits_valid_json(self):
rc, out, _err = self._run(close_stdout=False)
self.assertEqual(rc, 0)
parsed = json.loads(out.decode())
self.assertIn("suppressOutput", parsed)


if __name__ == "__main__":
unittest.main()
61 changes: 50 additions & 11 deletions claude-code/hooks/unbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ def log_error(message: str, category: str = 'general'):
report_error_to_gateway(message, category, _cached_api_key)


def _emit(payload) -> None:
"""Write a hook response to stdout, failing open if Claude Code has already
closed the pipe.

Claude closes the hook's stdout as soon as it has the response it needs (or
when it moves on / times the hook out). A subsequent write then raises
BrokenPipeError. That is not an actionable error — the response is simply
moot — so we swallow it silently instead of logging noise. Previously these
writes used bare print() and the BrokenPipeError surfaced as the dominant
line in error.log (see WEB-4745)."""
try:
text = payload if isinstance(payload, str) else json.dumps(payload)
sys.stdout.write(text + "\n")
sys.stdout.flush()
except (BrokenPipeError, OSError):
pass


def _read_policy_cache_raw() -> Optional[Dict]:
"""Read and JSON-parse the policy cache file. Returns None on missing/corrupt."""
try:
Expand Down Expand Up @@ -1512,15 +1530,15 @@ def main():

try:
input_data = sys.stdin.read().strip()

if not input_data:
print('{"suppressOutput": true}', flush=True)
_emit('{"suppressOutput": true}')
return

try:
event = json.loads(input_data)
except json.JSONDecodeError:
print('{"suppressOutput": true}', flush=True)
_emit('{"suppressOutput": true}')
return

hook_event_name = event.get('hook_event_name')
Expand All @@ -1531,15 +1549,15 @@ def main():
_device_serial() # warm the (slow) serial probe + cache once per session
_check_self_update()
_dispatch_discovery()
print("{}")
_emit("{}")
return
session_id = event.get('session_id')

# Handle PreToolUse - return immediately after decision is made
if hook_event_name == 'PreToolUse':
response = process_pre_tool_use(event, api_key)
response["suppressOutput"] = True
print(json.dumps(response), flush=True)
_emit(response)
return

# Handle UserPromptSubmit - check policy before processing
Expand All @@ -1554,7 +1572,7 @@ def main():
'event': event
})
response["suppressOutput"] = True
print(json.dumps(response), flush=True)
_emit(response)
return

# If allowed, continue to log the event (output printed at end)
Expand All @@ -1573,13 +1591,34 @@ def main():

cleanup_old_logs()

print('{"suppressOutput": true}', flush=True)

_emit('{"suppressOutput": true}')

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
Comment on lines +1596 to +1600

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.

except Exception as e:
# Still return empty JSON object to Claude Code to indicate completion
# Still return empty JSON object to Claude Code to indicate completion.
# _emit() is pipe-safe, so a dead pipe here won't trigger a second fault.
log_error(f"Exception in main: {str(e)}", 'general')
print('{"suppressOutput": true}', flush=True)
_emit('{"suppressOutput": true}')


if __name__ == '__main__':
main()
# Fail open on a closed stdout. Claude Code may close the hook's pipe before
# we finish writing; _emit() already swallows BrokenPipeError on each write,
# but the interpreter also flushes stdout at shutdown, which would re-raise
# against the dead pipe and print "Exception ignored ... BrokenPipeError"
# plus exit non-zero. Redirect stdout to devnull so that final flush is a
# no-op and the hook exits cleanly. (Standard CPython SIGPIPE recipe.) See WEB-4745.
try:
main()
sys.stdout.flush()
except BrokenPipeError:
pass
finally:
try:
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
except OSError:
pass