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