Skip to content
Merged
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
39 changes: 37 additions & 2 deletions src/claude_statusbar/render_thin.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,24 +184,59 @@ 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
Comment on lines +190 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

_payload_is_complete doesn't catch RecursionError from deeply-nested JSON.

json.loads raises RecursionError (a RuntimeError, not ValueError) on pathologically nested documents. That exception isn't caught here, nor by _consume_stdin's except (OSError, AttributeError): (Line 237), so it propagates out of _consume_stdin() and crashes render() entirely instead of falling back gracefully like malformed JSON does.

🛡️ Proposed fix
 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
+    except (ValueError, RecursionError):  # JSONDecodeError ⊂ ValueError
         return False
     return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 _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, RecursionError): # JSONDecodeError ⊂ ValueError
return False
return True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/claude_statusbar/render_thin.py` around lines 190 - 196, Update
_payload_is_complete to catch RecursionError alongside ValueError from
json.loads, returning False so deeply nested JSON follows the same
incomplete/malformed-payload fallback path without propagating through
_consume_stdin or render.



def _consume_stdin() -> bytes | None:
"""Read Claude Code's stdin payload (bytes) and return it.

Returns None if stdin is interactive or empty. Caller is responsible
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:
Expand Down
109 changes: 109 additions & 0 deletions tests/test_render_thin_stdin.py
Original file line number Diff line number Diff line change
@@ -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
Loading