Skip to content

Commit 87e2699

Browse files
fix multi-line buffering issue
Signed-off-by: Bradley-Butcher <brad@phoebe.ai>
1 parent 7efa8b3 commit 87e2699

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

src/claude_code_sdk/_internal/transport/subprocess_cli.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,17 +188,25 @@ async def read_stderr() -> None:
188188
if not line_str:
189189
continue
190190

191-
try:
192-
data = json.loads(line_str)
191+
# Split on newlines in case multiple JSON objects are buffered together
192+
json_lines = line_str.split("\n")
193+
194+
for json_line in json_lines:
195+
json_line = json_line.strip()
196+
if not json_line:
197+
continue
198+
193199
try:
194-
yield data
195-
except GeneratorExit:
196-
# Handle generator cleanup gracefully
197-
return
198-
except json.JSONDecodeError as e:
199-
if line_str.startswith("{") or line_str.startswith("["):
200-
raise SDKJSONDecodeError(line_str, e) from e
201-
continue
200+
data = json.loads(json_line)
201+
try:
202+
yield data
203+
except GeneratorExit:
204+
# Handle generator cleanup gracefully
205+
return
206+
except json.JSONDecodeError as e:
207+
if json_line.startswith("{") or json_line.startswith("["):
208+
raise SDKJSONDecodeError(json_line, e) from e
209+
continue
202210

203211
except anyio.ClosedResourceError:
204212
pass

tests/test_subprocess_buffering.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tests for subprocess transport buffering edge cases."""
2+
3+
import json
4+
from collections.abc import AsyncIterator
5+
from typing import Any
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
import pytest
9+
10+
from claude_code_sdk._errors import CLIJSONDecodeError
11+
from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
12+
from claude_code_sdk.types import ClaudeCodeOptions
13+
14+
15+
class MockTextReceiveStream:
16+
"""Mock TextReceiveStream for testing."""
17+
18+
def __init__(self, lines: list[str]) -> None:
19+
self.lines = lines
20+
self.index = 0
21+
22+
def __aiter__(self) -> AsyncIterator[str]:
23+
return self
24+
25+
async def __anext__(self) -> str:
26+
if self.index >= len(self.lines):
27+
raise StopAsyncIteration
28+
line = self.lines[self.index]
29+
self.index += 1
30+
return line
31+
32+
33+
class TestSubprocessBuffering:
34+
"""Test subprocess transport handling of buffered output."""
35+
36+
@pytest.mark.asyncio
37+
async def test_multiple_json_objects_on_single_line(self) -> None:
38+
"""Test parsing when multiple JSON objects are concatenated on a single line.
39+
40+
In some environments, stdout buffering can cause multiple distinct JSON
41+
objects to be delivered as a single line with embedded newlines.
42+
"""
43+
# Two valid JSON objects separated by a newline character
44+
json_obj1 = {"type": "message", "id": "msg1", "content": "First message"}
45+
json_obj2 = {"type": "result", "id": "res1", "status": "completed"}
46+
47+
# Simulate buffered output where both objects appear on one line
48+
buffered_line = json.dumps(json_obj1) + '\n' + json.dumps(json_obj2)
49+
50+
# Create transport
51+
transport = SubprocessCLITransport(
52+
prompt="test",
53+
options=ClaudeCodeOptions(),
54+
cli_path="/usr/bin/claude"
55+
)
56+
57+
# Mock the process and streams
58+
mock_process = MagicMock()
59+
mock_process.returncode = None
60+
mock_process.wait = AsyncMock(return_value=None)
61+
transport._process = mock_process
62+
63+
# Create mock stream that returns the buffered line
64+
transport._stdout_stream = MockTextReceiveStream([buffered_line]) # type: ignore[assignment]
65+
transport._stderr_stream = MockTextReceiveStream([]) # type: ignore[assignment]
66+
67+
# Collect all messages
68+
messages: list[Any] = []
69+
async for msg in transport.receive_messages():
70+
messages.append(msg)
71+
72+
# Verify both JSON objects were successfully parsed
73+
assert len(messages) == 2
74+
assert messages[0]["type"] == "message"
75+
assert messages[0]["id"] == "msg1"
76+
assert messages[0]["content"] == "First message"
77+
assert messages[1]["type"] == "result"
78+
assert messages[1]["id"] == "res1"
79+
assert messages[1]["status"] == "completed"

0 commit comments

Comments
 (0)