Skip to content

Commit f014184

Browse files
committed
fix: recover from oversized JSON-RPC frames
1 parent 3756c28 commit f014184

3 files changed

Lines changed: 156 additions & 5 deletions

File tree

src/acp/connection.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ class StreamEvent:
5858
StreamObserver = Callable[[StreamEvent], Awaitable[None] | None]
5959

6060

61+
class _OversizedLineSkipped(Exception):
62+
"""Raised after an oversized line has been discarded."""
63+
64+
6165
class Connection:
6266
"""Minimal JSON-RPC 2.0 connection over newline-delimited JSON frames."""
6367

@@ -153,7 +157,15 @@ async def send_notification(self, method: str, params: JsonValue | None = None)
153157
async def _receive_loop(self) -> None:
154158
try:
155159
while True:
156-
line = await asyncio.wait_for(self._reader.readline(), timeout=self._receive_timeout)
160+
try:
161+
line = await self._read_line()
162+
except _OversizedLineSkipped:
163+
logging.warning(
164+
"Skipped oversized JSON-RPC frame that exceeded the StreamReader line limit. "
165+
"The connection will continue with subsequent frames. If large frames are expected, "
166+
"increase the StreamReader limit, for example via stdio_buffer_limit_bytes when using run_agent."
167+
)
168+
continue
157169
if not line:
158170
break
159171
line = line.strip()
@@ -172,6 +184,36 @@ async def _receive_loop(self) -> None:
172184
raise RequestError.internal_error({"details": "Agent timeout"}) from None
173185
self._disconnect()
174186

187+
async def _read_line(self) -> bytes:
188+
try:
189+
return await self._wait_for_reader(self._reader.readuntil(b"\n"))
190+
except asyncio.IncompleteReadError as exc:
191+
return exc.partial
192+
except asyncio.LimitOverrunError as exc:
193+
await self._discard_oversized_line(exc.consumed)
194+
raise _OversizedLineSkipped from exc
195+
196+
async def _discard_oversized_line(self, consumed: int) -> None:
197+
while True:
198+
if consumed <= 0:
199+
consumed = 1
200+
if consumed > 0:
201+
try:
202+
await self._wait_for_reader(self._reader.readexactly(consumed))
203+
except asyncio.IncompleteReadError:
204+
return
205+
try:
206+
await self._wait_for_reader(self._reader.readuntil(b"\n"))
207+
except asyncio.IncompleteReadError:
208+
return
209+
except asyncio.LimitOverrunError as exc:
210+
consumed = exc.consumed
211+
else:
212+
return
213+
214+
async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes:
215+
return await asyncio.wait_for(awaitable, timeout=self._receive_timeout)
216+
175217
async def _process_message(self, message: dict[str, Any]) -> None:
176218
method = message.get("method")
177219
has_id = "id" in message

tests/real_user/test_stdio_limits.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ async def list_capabilities(self):
6969
[sys.executable, small_agent], input=large_msg, capture_output=True, text=True, timeout=2
7070
)
7171

72-
# Should have errors in stderr about the buffer limit
73-
assert "Error" in result.stderr or result.returncode != 0, (
74-
f"Expected error with small buffer, got: {result.stderr}"
75-
)
72+
assert result.returncode == 0
73+
assert "LimitOverrunError" not in result.stderr
74+
assert "Separator is found, but chunk is longer than limit" not in result.stderr
75+
assert "oversized JSON-RPC frame" in result.stderr
7676

7777
# Test 2: Large buffer (200KB) succeeds with large message (70KB)
7878
large_agent = os.path.join(tmpdir, "large_agent.py")

tests/test_connection_recovery.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
from typing import Any
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
import pytest
9+
10+
from acp.connection import Connection
11+
from acp.exceptions import RequestError
12+
13+
14+
async def _noop_handler(method: str, params: Any, is_notification: bool) -> Any:
15+
return None
16+
17+
18+
def _make_connection(
19+
*,
20+
limit: int = 128,
21+
receive_timeout: float | None = None,
22+
) -> tuple[Connection, asyncio.StreamReader]:
23+
reader = asyncio.StreamReader(limit=limit)
24+
transport = MagicMock()
25+
transport.is_closing.return_value = False
26+
protocol = AsyncMock()
27+
writer = asyncio.StreamWriter(transport, protocol, reader, asyncio.get_running_loop())
28+
conn = Connection(_noop_handler, writer, reader, listening=False, receive_timeout=receive_timeout)
29+
return conn, reader
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_receive_loop_recovers_from_oversized_frame(caplog: pytest.LogCaptureFixture) -> None:
34+
conn, reader = _make_connection(limit=128)
35+
processed: list[str] = []
36+
37+
async def tracking_process(message: dict[str, Any]) -> None:
38+
processed.append(message["method"])
39+
40+
conn._process_message = tracking_process # type: ignore[method-assign]
41+
oversized = {"jsonrpc": "2.0", "method": "too-large", "params": {"data": "X" * 256}}
42+
survivor = {"jsonrpc": "2.0", "method": "survivor"}
43+
reader.feed_data(json.dumps(oversized).encode() + b"\n" + json.dumps(survivor).encode() + b"\n")
44+
reader.feed_eof()
45+
46+
with caplog.at_level("WARNING"):
47+
await conn._receive_loop()
48+
await conn.close()
49+
50+
assert processed == ["survivor"]
51+
assert any("oversized JSON-RPC frame" in record.message for record in caplog.records)
52+
53+
54+
@pytest.mark.asyncio
55+
async def test_receive_loop_recovers_from_consecutive_oversized_frames() -> None:
56+
conn, reader = _make_connection(limit=128)
57+
processed: list[str] = []
58+
59+
async def tracking_process(message: dict[str, Any]) -> None:
60+
processed.append(message["method"])
61+
62+
conn._process_message = tracking_process # type: ignore[method-assign]
63+
for index in range(2):
64+
oversized = {"jsonrpc": "2.0", "method": f"too-large-{index}", "params": {"data": "Y" * 256}}
65+
reader.feed_data(json.dumps(oversized).encode() + b"\n")
66+
survivor = {"jsonrpc": "2.0", "method": "survivor"}
67+
reader.feed_data(json.dumps(survivor).encode() + b"\n")
68+
reader.feed_eof()
69+
70+
await conn._receive_loop()
71+
await conn.close()
72+
73+
assert processed == ["survivor"]
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_receive_loop_handles_eof_during_oversized_frame() -> None:
78+
conn, reader = _make_connection(limit=64)
79+
reader.feed_data(b"X" * 256)
80+
reader.feed_eof()
81+
82+
await conn._receive_loop()
83+
await conn.close()
84+
85+
assert conn._disconnected is True
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_receive_loop_keeps_timeout_semantics() -> None:
90+
conn, _reader = _make_connection(receive_timeout=0.01)
91+
92+
with pytest.raises(RequestError) as exc_info:
93+
await conn._receive_loop()
94+
await conn.close()
95+
96+
exc = exc_info.value
97+
assert isinstance(exc, RequestError)
98+
assert str(exc) == "Internal error"
99+
assert exc.data == {"details": "Agent timeout"}
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_receive_loop_does_not_swallow_unrelated_reader_error() -> None:
104+
conn, reader = _make_connection()
105+
reader.set_exception(ValueError("reader failed"))
106+
107+
with pytest.raises(ValueError, match="reader failed"):
108+
await conn._receive_loop()
109+
await conn.close()

0 commit comments

Comments
 (0)