From 23ac655ed6f12394e1cbd6c147f285cadd05e1b1 Mon Sep 17 00:00:00 2001 From: CelChe Date: Sat, 25 Jul 2026 01:19:23 +0100 Subject: [PATCH] fix(render): stop at end of JSON payload instead of waiting for EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_consume_stdin()` calls `sys.stdin.buffer.read()`, which returns only at EOF — and EOF requires *every* write handle on the pipe to close, not just the one Claude Code wrote through. Claude Code spawns the statusLine via a shell (`bash -c "cs render"`); on Windows a sibling process that inherited the write handle keeps the pipe open after that shell exits. Result: the payload has arrived in full, the render never happens, and the process lives forever. Observed on Windows 11 / v3.32.0 as a steady drip of `cs render` processes — parent gone, still resident minutes later, one every 20-40s across concurrent sessions: 16200 age= 281s parent=27156 GONE 19044 age= 277s parent=8356 GONE 25480 age= 266s parent=35288 GONE Confirmed directly: write the payload, never close stdin → the process is still alive after 4s; close stdin → it exits immediately. The payload is one JSON document, so read until it parses and stop there. Falls back to reading to EOF when the input is not JSON, so nothing is truncated. One extra json.loads per chunk (~0.05ms — the payload is a couple of KB and arrives in a single read). Co-Authored-By: Claude Opus 5 (1M context) --- src/claude_statusbar/render_thin.py | 39 +++++++++- tests/test_render_thin_stdin.py | 109 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/test_render_thin_stdin.py diff --git a/src/claude_statusbar/render_thin.py b/src/claude_statusbar/render_thin.py index ac5be46..5b33f8e 100644 --- a/src/claude_statusbar/render_thin.py +++ b/src/claude_statusbar/render_thin.py @@ -184,6 +184,18 @@ def _append_suffix(content: str, suffix: str) -> str: return content + suffix +_STDIN_CHUNK = 64 * 1024 + + +def _payload_is_complete(buf: bytearray) -> bool: + """True once `buf` holds a whole JSON document.""" + try: + json.loads(buf.decode("utf-8", errors="replace")) + except ValueError: # JSONDecodeError ⊂ ValueError + return False + return True + + def _consume_stdin() -> bytes | None: """Read Claude Code's stdin payload (bytes) and return it. @@ -191,17 +203,40 @@ def _consume_stdin() -> bytes | None: for both (a) writing it to per-session + legacy last_stdin.json so the daemon sees it on the next tick, and (b) replaying it into sys.stdin if the inline fallback path needs to consume it. + + Stops at the end of the JSON document rather than at EOF. `read()` waits + for *every* write handle on the pipe to close, which is not something the + client controls: Claude Code spawns the statusLine through a shell, and on + Windows a sibling process that inherited the write handle keeps the pipe + open after that shell exits. The payload has arrived in full, but read() + never returns and the process lives forever — observed as a steady drip of + `cs render` processes, parent gone, each still resident minutes later. + + One extra json.loads per chunk; the payload is a couple of KB and arrives + in a single read, so this costs ~0.05ms on the render hot path. """ try: if sys.stdin.isatty(): return None except (OSError, ValueError): return None + data = bytearray() try: - data = sys.stdin.buffer.read() + # read1() returns what one raw read yields; read() would block until + # the buffer is full or EOF, which is the behaviour being avoided. + read_some = getattr(sys.stdin.buffer, "read1", None) + if read_some is None: # exotic stdin replacement — keep the old path + return sys.stdin.buffer.read() or None + while True: + chunk = read_some(_STDIN_CHUNK) + if not chunk: + break # EOF — the writer closed as expected + data += chunk + if _payload_is_complete(data): + break # whole document in hand; don't wait on the pipe except (OSError, AttributeError): return None - return data or None + return bytes(data) or None def _extract_session_id(payload: bytes) -> str: diff --git a/tests/test_render_thin_stdin.py b/tests/test_render_thin_stdin.py new file mode 100644 index 0000000..e4e87fd --- /dev/null +++ b/tests/test_render_thin_stdin.py @@ -0,0 +1,109 @@ +"""`cs render` must not outlive the payload it was handed. + +`sys.stdin.buffer.read()` returns at EOF, and EOF needs *every* write handle +on the pipe to close — not just the shell Claude Code spawned the statusLine +through. On Windows a sibling process that inherited the handle keeps the pipe +open after that shell exits, so the render process blocks forever with the +complete payload already in memory. +""" + +import json +import sys + +import pytest + +from claude_statusbar import render_thin + + +PAYLOAD = json.dumps({ + "session_id": "abc-123", + "model": {"id": "claude-opus-5", "display_name": "Opus 5"}, + "context_window": {"context_window_size": 1_000_000, + "used_percentage": 6, + "total_input_tokens": 63_824}, +}).encode("utf-8") + + +class _FakeBuffer: + """Hands out `chunks`, then blocks — standing in for a pipe that never + reaches EOF because someone else still holds the write end.""" + + def __init__(self, chunks): + self._chunks = list(chunks) + self.reads = 0 + + def read1(self, _size): + self.reads += 1 + if self._chunks: + return self._chunks.pop(0) + raise AssertionError("read1 called after the payload was complete " + "— this is the call that hangs in production") + + def read(self, *_a): # pragma: no cover - guard, must never be reached + raise AssertionError("read() would block until EOF") + + +class _FakeStdin: + def __init__(self, chunks, tty=False): + self.buffer = _FakeBuffer(chunks) + self._tty = tty + + def isatty(self): + return self._tty + + +def test_stops_at_end_of_json_without_waiting_for_eof(monkeypatch): + stdin = _FakeStdin([PAYLOAD]) + monkeypatch.setattr(sys, "stdin", stdin) + + assert render_thin._consume_stdin() == PAYLOAD + assert stdin.buffer.reads == 1 + + +def test_reassembles_a_payload_split_across_chunks(monkeypatch): + half = len(PAYLOAD) // 2 + stdin = _FakeStdin([PAYLOAD[:half], PAYLOAD[half:]]) + monkeypatch.setattr(sys, "stdin", stdin) + + assert render_thin._consume_stdin() == PAYLOAD + assert stdin.buffer.reads == 2 + + +def test_non_json_input_still_reads_to_eof(monkeypatch): + """No JSON document to bound on — fall back to the old EOF behaviour + rather than truncating whatever the caller sent.""" + stdin = _FakeStdin([b"not json", b""]) + monkeypatch.setattr(sys, "stdin", stdin) + + assert render_thin._consume_stdin() == b"not json" + + +def test_interactive_stdin_returns_none(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin([PAYLOAD], tty=True)) + + assert render_thin._consume_stdin() is None + + +def test_empty_stdin_returns_none(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin([b""])) + + assert render_thin._consume_stdin() is None + + +def test_session_id_survives_the_bounded_read(monkeypatch): + """The bytes handed back must still route to the right session bucket.""" + monkeypatch.setattr(sys, "stdin", _FakeStdin([PAYLOAD])) + + payload = render_thin._consume_stdin() + + assert render_thin._extract_session_id(payload) == "abc-123" + + +@pytest.mark.parametrize("buf,expected", [ + (bytearray(b'{"a": 1}'), True), + (bytearray(b'{"a": 1'), False), + (bytearray(b'{"a": 1}\n'), True), + (bytearray(b''), False), +]) +def test_payload_completeness_check(buf, expected): + assert render_thin._payload_is_complete(buf) is expected