Skip to content

Commit 6bcf11a

Browse files
authored
fix: recover from oversized JSON-RPC frames (#111)
* fix: recover from oversized JSON-RPC frames * fix: read oversized frames in chunks
1 parent 3756c28 commit 6bcf11a

3 files changed

Lines changed: 190 additions & 31 deletions

File tree

src/acp/connection.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async def send_notification(self, method: str, params: JsonValue | None = None)
153153
async def _receive_loop(self) -> None:
154154
try:
155155
while True:
156-
line = await asyncio.wait_for(self._reader.readline(), timeout=self._receive_timeout)
156+
line = await self._read_line()
157157
if not line:
158158
break
159159
line = line.strip()
@@ -172,6 +172,24 @@ async def _receive_loop(self) -> None:
172172
raise RequestError.internal_error({"details": "Agent timeout"}) from None
173173
self._disconnect()
174174

175+
async def _read_line(self) -> bytes:
176+
chunks: list[bytes] = []
177+
try:
178+
while True:
179+
try:
180+
line = await self._wait_for_reader(self._reader.readuntil(b"\n"))
181+
except asyncio.LimitOverrunError as exc:
182+
chunks.append(await self._wait_for_reader(self._reader.readexactly(exc.consumed)))
183+
else:
184+
chunks.append(line)
185+
return b"".join(chunks)
186+
except asyncio.IncompleteReadError as exc:
187+
chunks.append(exc.partial)
188+
return b"".join(chunks)
189+
190+
async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes:
191+
return await asyncio.wait_for(awaitable, timeout=self._receive_timeout)
192+
175193
async def _process_message(self, message: dict[str, Any]) -> None:
176194
method = message.get("method")
177195
has_id = "id" in message

tests/real_user/test_stdio_limits.py

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -48,46 +48,62 @@ async def test_spawn_stdio_transport_custom_limit_handles_large_line() -> None:
4848
async def test_run_agent_stdio_buffer_limit() -> None:
4949
"""Test that run_agent with different buffer limits can handle appropriately sized messages."""
5050
with tempfile.TemporaryDirectory() as tmpdir:
51-
# Test 1: Small buffer (1KB) fails with large message (70KB)
51+
# Test 1: Small buffer (1KB) reads a large message (70KB) in chunks
5252
small_agent = os.path.join(tmpdir, "small_agent.py")
5353
with open(small_agent, "w") as f:
54-
f.write("""
55-
import asyncio
56-
from acp.core import run_agent
57-
from acp.interfaces import Agent
58-
59-
class TestAgent(Agent):
60-
async def list_capabilities(self):
61-
return {"capabilities": {}}
62-
63-
asyncio.run(run_agent(TestAgent(), stdio_buffer_limit_bytes=1024))
64-
""")
65-
66-
# Send a 70KB message - should fail with 1KB buffer
67-
large_msg = '{"jsonrpc":"2.0","method":"test","params":{"data":"' + "X" * LARGE_LINE_SIZE + '"}}\n'
54+
f.write(
55+
textwrap.dedent(
56+
"""
57+
import asyncio
58+
from acp.core import run_agent
59+
from acp.interfaces import Agent
60+
from acp.schema import InitializeResponse
61+
62+
class TestAgent(Agent):
63+
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
64+
return InitializeResponse(protocol_version=protocol_version)
65+
66+
asyncio.run(run_agent(TestAgent(), stdio_buffer_limit_bytes=1024))
67+
"""
68+
).strip()
69+
)
70+
71+
# Send a 70KB message - should be read in chunks despite the 1KB buffer
72+
large_msg = (
73+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"_meta":{"data":"'
74+
+ "X" * LARGE_LINE_SIZE
75+
+ '"}}}\n'
76+
)
6877
result = subprocess.run( # noqa: S603
6978
[sys.executable, small_agent], input=large_msg, capture_output=True, text=True, timeout=2
7079
)
7180

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-
)
81+
assert result.returncode == 0
82+
assert "LimitOverrunError" not in result.stderr
83+
assert "Separator is found, but chunk is longer than limit" not in result.stderr
84+
assert "oversized JSON-RPC frame" not in result.stderr
85+
assert '"id":1' in result.stdout
86+
assert '"protocolVersion":1' in result.stdout
7687

7788
# Test 2: Large buffer (200KB) succeeds with large message (70KB)
7889
large_agent = os.path.join(tmpdir, "large_agent.py")
7990
with open(large_agent, "w") as f:
80-
f.write(f"""
81-
import asyncio
82-
from acp.core import run_agent
83-
from acp.interfaces import Agent
84-
85-
class TestAgent(Agent):
86-
async def list_capabilities(self):
87-
return {{"capabilities": {{}}}}
88-
89-
asyncio.run(run_agent(TestAgent(), stdio_buffer_limit_bytes={LARGE_LINE_SIZE * 3}))
90-
""")
91+
f.write(
92+
textwrap.dedent(
93+
f"""
94+
import asyncio
95+
from acp.core import run_agent
96+
from acp.interfaces import Agent
97+
from acp.schema import InitializeResponse
98+
99+
class TestAgent(Agent):
100+
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
101+
return InitializeResponse(protocol_version=protocol_version)
102+
103+
asyncio.run(run_agent(TestAgent(), stdio_buffer_limit_bytes={LARGE_LINE_SIZE * 3}))
104+
"""
105+
).strip()
106+
)
91107

92108
# Same message, but with a buffer 3x the size - should handle it
93109
result = subprocess.run( # noqa: S603
@@ -98,3 +114,5 @@ async def list_capabilities(self):
98114
# (it may have other errors from invalid JSON-RPC, but not buffer overrun)
99115
if "LimitOverrunError" in result.stderr or "buffer" in result.stderr.lower():
100116
pytest.fail(f"Large buffer still hit limit error: {result.stderr}")
117+
assert '"id":1' in result.stdout
118+
assert '"protocolVersion":1' in result.stdout

tests/test_connection_recovery.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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_handles_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 == ["too-large", "survivor"]
51+
assert "oversized JSON-RPC frame" not in caplog.text
52+
53+
54+
@pytest.mark.asyncio
55+
async def test_receive_loop_handles_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 == ["too-large-0", "too-large-1", "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_keeps_timeout_semantics_while_reading_oversized_frame() -> None:
104+
conn, reader = _make_connection(limit=64, receive_timeout=0.01)
105+
reader.feed_data(b"X" * 256)
106+
107+
with pytest.raises(RequestError) as exc_info:
108+
await conn._receive_loop()
109+
await conn.close()
110+
111+
exc = exc_info.value
112+
assert isinstance(exc, RequestError)
113+
assert exc.data == {"details": "Agent timeout"}
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_receive_loop_does_not_swallow_unrelated_reader_error() -> None:
118+
conn, reader = _make_connection()
119+
reader.set_exception(ValueError("reader failed"))
120+
121+
with pytest.raises(ValueError, match="reader failed"):
122+
await conn._receive_loop()
123+
await conn.close()

0 commit comments

Comments
 (0)