-
Notifications
You must be signed in to change notification settings - Fork 1
fix(claude-code hook): fail open on closed stdout instead of logging Broken pipe (WEB-4745) #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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') | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stderrafterproc.wait()with stdout closedproc.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 isproc.communicate()(with stdin pre-supplied) or draining stderr concurrently via a thread, rather than sequentially waiting then reading.