Skip to content

Commit 11dcb7d

Browse files
committed
fix(connection): retain ordered responses across EOF
1 parent 921dc9a commit 11dcb7d

2 files changed

Lines changed: 125 additions & 15 deletions

File tree

src/acp/connection.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ class StreamEvent:
5555
message: dict[str, Any]
5656

5757

58+
@dataclass(slots=True)
59+
class _RequestNotificationState:
60+
start_sequence: int
61+
barrier: asyncio.Future[None]
62+
response_received: bool = False
63+
64+
5865
StreamObserver = Callable[[StreamEvent], Awaitable[None] | None]
5966

6067

@@ -81,7 +88,7 @@ def __init__(
8188
# response cannot overtake notifications received during that request.
8289
self._notification_sequence = 0
8390
self._pending_notifications: dict[int, asyncio.Future[None]] = {}
84-
self._request_notification_starts: dict[int, int] = {}
91+
self._request_notifications: dict[int, _RequestNotificationState] = {}
8592
self._state = state_store or InMemoryMessageStateStore()
8693
self._tasks = TaskSupervisor(source="acp.Connection")
8794
self._tasks.add_error_handler(self._on_task_error)
@@ -126,6 +133,7 @@ async def close(self) -> None:
126133
await self._dispatcher.stop()
127134
await self._transport.close()
128135
await self._tasks.shutdown()
136+
self._release_request_barriers()
129137
self._state.reject_all_outgoing(ConnectionError("Connection closed"))
130138

131139
async def main_loop(self) -> None:
@@ -150,7 +158,11 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An
150158
self._raise_if_unavailable()
151159
request_id = self._next_request_id
152160
self._next_request_id += 1
153-
self._request_notification_starts[request_id] = self._notification_sequence
161+
notification_state = _RequestNotificationState(
162+
start_sequence=self._notification_sequence,
163+
barrier=asyncio.get_running_loop().create_future(),
164+
)
165+
self._request_notifications[request_id] = notification_state
154166
future = self._state.register_outgoing(request_id, method)
155167
payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
156168
try:
@@ -159,14 +171,18 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An
159171
# A synchronous send failure (e.g. HTTP POST rejected before any
160172
# JSON-RPC response exists) must reject the correlated future so the
161173
# caller gets a real, attributable error.
162-
self._request_notification_starts.pop(request_id, None)
174+
self._request_notifications.pop(request_id, None)
163175
self._state.reject_outgoing(request_id, exc)
164176
raise
165177
self._notify_observers(StreamDirection.OUTGOING, payload)
166178
try:
179+
await notification_state.barrier
167180
return await future
181+
except asyncio.CancelledError:
182+
future.cancel()
183+
raise
168184
finally:
169-
self._request_notification_starts.pop(request_id, None)
185+
self._request_notifications.pop(request_id, None)
170186

171187
async def send_notification(self, method: str, params: JsonValue | None = None) -> None:
172188
self._raise_if_unavailable()
@@ -204,29 +220,41 @@ async def _process_message(self, message: dict[str, Any]) -> None:
204220
return
205221
if has_id:
206222
request_id = message["id"]
223+
notification_state = self._request_notifications.get(request_id)
224+
if notification_state is None:
225+
await self._handle_response(message)
226+
return
207227
# Excluding notifications received before this request began keeps
208228
# notification handlers free to make nested requests without those
209229
# responses waiting on the handler that issued them.
210-
start_sequence = self._request_notification_starts.get(request_id, self._notification_sequence)
211230
preceding_notifications = tuple(
212-
completion for sequence, completion in self._pending_notifications.items() if sequence > start_sequence
231+
completion
232+
for sequence, completion in self._pending_notifications.items()
233+
if sequence > notification_state.start_sequence
213234
)
235+
# Resolve the stored response before waiting. Otherwise EOF can
236+
# reject a response that was already received while its preceding
237+
# notification handler is still running.
238+
await self._handle_response(message)
239+
notification_state.response_received = True
214240
if preceding_notifications:
215241
self._tasks.create(
216-
self._handle_response_after_notifications(message, preceding_notifications),
217-
name="acp.Connection.response",
218-
on_error=self._on_receive_error,
242+
self._release_response_after_notifications(notification_state, preceding_notifications),
243+
name="acp.Connection.response-barrier",
219244
)
220-
else:
221-
await self._handle_response(message)
245+
elif not notification_state.barrier.done():
246+
notification_state.barrier.set_result(None)
222247

223-
async def _handle_response_after_notifications(
248+
async def _release_response_after_notifications(
224249
self,
225-
message: dict[str, Any],
250+
notification_state: _RequestNotificationState,
226251
preceding_notifications: tuple[asyncio.Future[None], ...],
227252
) -> None:
228-
await asyncio.gather(*(asyncio.shield(completion) for completion in preceding_notifications))
229-
await self._handle_response(message)
253+
try:
254+
await asyncio.gather(*(asyncio.shield(completion) for completion in preceding_notifications))
255+
finally:
256+
if not notification_state.barrier.done():
257+
notification_state.barrier.set_result(None)
230258

231259
def _notify_observers(self, direction: StreamDirection, message: dict[str, Any]) -> None:
232260
if not self._observers:
@@ -357,8 +385,16 @@ def _disconnect(self) -> None:
357385
if self._disconnected:
358386
return
359387
self._disconnected = True
388+
self._release_request_barriers(response_received=False)
360389
self._state.reject_all_outgoing(ConnectionError("Connection closed"))
361390

391+
def _release_request_barriers(self, *, response_received: bool | None = None) -> None:
392+
for state in self._request_notifications.values():
393+
if response_received is not None and state.response_received is not response_received:
394+
continue
395+
if not state.barrier.done():
396+
state.barrier.set_result(None)
397+
362398
def _raise_if_unavailable(self) -> None:
363399
if self._disconnected or self._closed:
364400
raise ConnectionError("Connection closed")

tests/test_rpc.py

Lines changed: 74 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,79 @@ 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_response_received_before_eof_waits_for_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": "answer"},
203+
}
204+
response = {"jsonrpc": "2.0", "id": request_message["id"], "result": {"stopReason": "end_turn"}}
205+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
206+
await server.server_writer.drain()
207+
await asyncio.wait_for(notification_started.wait(), timeout=1)
208+
209+
server.server_writer.close()
210+
await server.server_writer.wait_closed()
211+
await asyncio.sleep(0)
212+
assert not request.done()
213+
214+
release_notification.set()
215+
assert await asyncio.wait_for(request, timeout=1) == {"stopReason": "end_turn"}
216+
await conn.close()
217+
218+
219+
@pytest.mark.asyncio
220+
async def test_error_response_waits_for_preceding_notification(server):
221+
notification_started = asyncio.Event()
222+
release_notification = asyncio.Event()
223+
224+
async def handler(method: str, params: Any, is_notification: bool) -> None:
225+
assert method == "session/update"
226+
assert is_notification
227+
notification_started.set()
228+
await release_notification.wait()
229+
230+
conn = Connection(handler, server.client_writer, server.client_reader)
231+
request = asyncio.create_task(conn.send_request("session/prompt", {"sessionId": "sess"}))
232+
233+
request_message = json.loads(await server.server_reader.readline())
234+
notification = {
235+
"jsonrpc": "2.0",
236+
"method": "session/update",
237+
"params": {"sessionId": "sess", "update": "partial answer"},
238+
}
239+
response = {
240+
"jsonrpc": "2.0",
241+
"id": request_message["id"],
242+
"error": {"code": -32603, "message": "prompt failed"},
243+
}
244+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
245+
await server.server_writer.drain()
246+
247+
await asyncio.wait_for(notification_started.wait(), timeout=1)
248+
await asyncio.sleep(0)
249+
assert not request.done()
250+
251+
release_notification.set()
252+
with pytest.raises(RequestError, match="prompt failed"):
253+
await asyncio.wait_for(request, timeout=1)
254+
await conn.close()
255+
256+
183257
@pytest.mark.asyncio
184258
async def test_notification_can_await_nested_request(server):
185259
nested_result: Any = None

0 commit comments

Comments
 (0)