Skip to content

Commit d07f3e2

Browse files
committed
fix(connection): retain ordered responses across EOF
1 parent b99e8c2 commit d07f3e2

2 files changed

Lines changed: 88 additions & 15 deletions

File tree

src/acp/connection.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ class StreamEvent:
3535
message: dict[str, Any]
3636

3737

38+
@dataclass(slots=True)
39+
class _RequestNotificationState:
40+
start_sequence: int
41+
barrier: asyncio.Future[None]
42+
response_received: bool = False
43+
44+
3845
StreamObserver = Callable[[StreamEvent], Awaitable[None] | None]
3946

4047

@@ -58,7 +65,7 @@ def __init__(
5865
# response cannot overtake notifications received during that request.
5966
self._notification_sequence = 0
6067
self._pending_notifications: dict[int, asyncio.Future[None]] = {}
61-
self._request_notification_starts: dict[int, int] = {}
68+
self._request_notifications: dict[int, _RequestNotificationState] = {}
6269
self._tasks = TaskSupervisor(source="acp.Connection")
6370
self._tasks.add_error_handler(self._on_task_error)
6471
self._closed = False
@@ -89,11 +96,13 @@ async def close(self) -> None:
8996
if self._closed:
9097
return
9198
self._closed = True
99+
self._release_request_barriers(response_received=False)
92100
self._reject_all_outgoing(ConnectionError("Connection closed"))
93101
try:
94102
await self._transport.close()
95103
finally:
96104
await self._tasks.shutdown()
105+
self._release_request_barriers()
97106

98107
async def main_loop(self) -> None:
99108
try:
@@ -117,26 +126,31 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An
117126
self._raise_if_unavailable()
118127
request_id = self._next_request_id
119128
self._next_request_id += 1
120-
self._request_notification_starts[request_id] = self._notification_sequence
129+
notification_state = _RequestNotificationState(
130+
start_sequence=self._notification_sequence,
131+
barrier=asyncio.get_running_loop().create_future(),
132+
)
133+
self._request_notifications[request_id] = notification_state
121134
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
122135
self._pending[request_id] = future
123136
payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
124137
try:
125138
await self._transport.send(payload)
126139
except BaseException:
127-
self._request_notification_starts.pop(request_id, None)
140+
self._request_notifications.pop(request_id, None)
128141
self._pending.pop(request_id, None)
129142
future.cancel()
130143
raise
131144
self._notify_observers(StreamDirection.OUTGOING, payload)
132145
try:
146+
await notification_state.barrier
133147
return await future
134148
except asyncio.CancelledError:
135149
self._pending.pop(request_id, None)
136150
future.cancel()
137151
raise
138152
finally:
139-
self._request_notification_starts.pop(request_id, None)
153+
self._request_notifications.pop(request_id, None)
140154

141155
async def send_notification(self, method: str, params: JsonValue | None = None) -> None:
142156
self._raise_if_unavailable()
@@ -179,29 +193,41 @@ def _process_message(self, message: dict[str, Any]) -> None:
179193
return
180194
if has_id: # this is a response, {"id", "result" | "error"}
181195
request_id = message["id"]
196+
notification_state = self._request_notifications.get(request_id)
197+
if notification_state is None:
198+
self._handle_response(message)
199+
return
182200
# Excluding notifications received before this request began keeps
183201
# notification handlers free to make nested requests without those
184202
# responses waiting on the handler that issued them.
185-
start_sequence = self._request_notification_starts.get(request_id, self._notification_sequence)
186203
preceding_notifications = tuple(
187-
completion for sequence, completion in self._pending_notifications.items() if sequence > start_sequence
204+
completion
205+
for sequence, completion in self._pending_notifications.items()
206+
if sequence > notification_state.start_sequence
188207
)
208+
# Resolve the stored response before waiting. Otherwise EOF can
209+
# reject a response that was already received while its preceding
210+
# notification handler is still running.
211+
self._handle_response(message)
212+
notification_state.response_received = True
189213
if preceding_notifications:
190214
self._tasks.create(
191-
self._handle_response_after_notifications(message, preceding_notifications),
192-
name="acp.Connection.response",
193-
on_error=self._on_receive_error,
215+
self._release_response_after_notifications(notification_state, preceding_notifications),
216+
name="acp.Connection.response-barrier",
194217
)
195-
else:
196-
self._handle_response(message)
218+
elif not notification_state.barrier.done():
219+
notification_state.barrier.set_result(None)
197220

198-
async def _handle_response_after_notifications(
221+
async def _release_response_after_notifications(
199222
self,
200-
message: dict[str, Any],
223+
notification_state: _RequestNotificationState,
201224
preceding_notifications: tuple[asyncio.Future[None], ...],
202225
) -> None:
203-
await asyncio.gather(*(asyncio.shield(completion) for completion in preceding_notifications))
204-
self._handle_response(message)
226+
try:
227+
await asyncio.gather(*(asyncio.shield(completion) for completion in preceding_notifications))
228+
finally:
229+
if not notification_state.barrier.done():
230+
notification_state.barrier.set_result(None)
205231

206232
async def _run_tracked_notification(
207233
self,
@@ -313,6 +339,7 @@ def _disconnect(self) -> None:
313339
if self._disconnected:
314340
return
315341
self._disconnected = True
342+
self._release_request_barriers(response_received=False)
316343
self._reject_all_outgoing(ConnectionError("Connection closed"))
317344

318345
def _reject_all_outgoing(self, error: BaseException) -> None:
@@ -322,6 +349,13 @@ def _reject_all_outgoing(self, error: BaseException) -> None:
322349
if not future.done():
323350
future.set_exception(error)
324351

352+
def _release_request_barriers(self, *, response_received: bool | None = None) -> None:
353+
for state in self._request_notifications.values():
354+
if response_received is not None and state.response_received is not response_received:
355+
continue
356+
if not state.barrier.done():
357+
state.barrier.set_result(None)
358+
325359
def _raise_if_unavailable(self) -> None:
326360
if self._disconnected or self._closed:
327361
raise ConnectionError("Connection closed")

tests/test_rpc.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from acp.connection import Connection
3636
from acp.core import AgentSideConnection, ClientSideConnection
37+
from acp.exceptions import RequestError
3738
from acp.schema import (
3839
AgentMessageChunk,
3940
AllowedOutcome,
@@ -180,6 +181,44 @@ async def handler(method: str, params: Any, is_notification: bool) -> None:
180181
await conn.close()
181182

182183

184+
@pytest.mark.asyncio
185+
async def test_error_response_waits_for_preceding_notification(server):
186+
notification_started = asyncio.Event()
187+
release_notification = asyncio.Event()
188+
189+
async def handler(method: str, params: Any, is_notification: bool) -> None:
190+
assert method == "session/update"
191+
assert is_notification
192+
notification_started.set()
193+
await release_notification.wait()
194+
195+
conn = Connection(handler, server.client_writer, server.client_reader)
196+
request = asyncio.create_task(conn.send_request("session/prompt", {"sessionId": "sess"}))
197+
198+
request_message = json.loads(await server.server_reader.readline())
199+
notification = {
200+
"jsonrpc": "2.0",
201+
"method": "session/update",
202+
"params": {"sessionId": "sess", "update": "partial answer"},
203+
}
204+
response = {
205+
"jsonrpc": "2.0",
206+
"id": request_message["id"],
207+
"error": {"code": -32603, "message": "prompt failed"},
208+
}
209+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
210+
await server.server_writer.drain()
211+
212+
await asyncio.wait_for(notification_started.wait(), timeout=1)
213+
await asyncio.sleep(0)
214+
assert not request.done()
215+
216+
release_notification.set()
217+
with pytest.raises(RequestError, match="prompt failed"):
218+
await asyncio.wait_for(request, timeout=1)
219+
await conn.close()
220+
221+
183222
@pytest.mark.asyncio
184223
async def test_notification_can_await_nested_request(server):
185224
nested_result: Any = None

0 commit comments

Comments
 (0)