anthropics / anthropics/claude-agent-sdk-python

Pending outgoing control requests (interrupt, set_permission_mode, etc.) hang for the full 60s timeout after close()/disconnect() instead of failing fast

Abierto
#1,094 0 comentarios 0 reacciones 0 asignados Ver en GitHub
bug
Lenguaje dominante
Python
Estrellas
8.1k
Forks
1.3k
Métricas de merge de PR
Métricas de PR pendientes

Descripción

## Related Issues / PRs

- #387 / #388 ("CLI exit errors not propagated to pending control requests") — same bug *family*: a pending outgoing control request left unsignaled, so the caller waits out the full timeout instead of failing immediately. #388's fix (`query.py`, the `except Exception as e:` block in `_read_messages`) only covers the case where the CLI process crashes/exits unexpectedly during read. It does not cover a caller-initiated graceful `close()`/`disconnect()` — a much more common trigger (e.g. an "interrupt/stop" UI action) — which goes through a different exception branch entirely and is not signaled at all. This report is the `close()`/`disconnect()` counterpart to #388.

## Description

`Query` (`src/claude_agent_sdk/_internal/query.py`) tracks in-flight *outgoing* control requests (wire subtypes `interrupt`, `set_permission_mode`, `set_model`, `rewind_files`, `mcp_reconnect`, `mcp_toggle`, `stop_task`, `mcp_status`, `get_context_usage`, `initialize`) in two dicts:

```python
# lines 114-115
self.pending_control_responses: dict[str, anyio.Event] = {}
self.pending_control_results: dict[str, dict[str, Any] | Exception] = {}
```

`_send_control_request` (lines 501-546) registers an `anyio.Event` for the request (line 519) and waits on it under `anyio.fail_after(timeout)` (default 60s):

```python
# lines 531-546
try:
with anyio.fail_after(timeout):
await event.wait()
result = self.pending_control_results.pop(request_id)
self.pending_control_responses.pop(request_id, None)
...
except TimeoutError as e:
self.pending_control_responses.pop(request_id, None)
self.pending_control_results.pop(request_id, None)
raise Exception(f"Control request timeout: {request.get('subtype')}") from e
```

There are exactly two places that set the event and unblock this wait:

1. Normal resolution in `_read_messages`, when a `control_response` message arrives (lines 260-268, `event.set()` at line 268).
2. The bulk-fail loop added by #388, at lines 328-333, **inside `except Exception as e:`** (line 328) in `_read_messages`.

But `_read_messages` has an earlier, separate handler for cancellation:

```python
# lines 324-327
except anyio.get_cancelled_exc_class():
# Task was cancelled - this is expected behavior
logger.debug("Read task cancelled")
raise # Re-raise to properly handle cancellation
except Exception as e:
# Signal all pending control requests so they fail fast instead of timing out
for request_id, event in list(self.pending_control_responses.items()):
...
```

`close()` → `_close_impl()` (lines 886-907) cancels the read task directly:

```python
# from _close_impl
if self._read_task is not None and not self._read_task.done():
self._read_task.cancel()
await self._read_task.wait()
```

This makes the read task raise `anyio.get_cancelled_exc_class()`, which is caught by the `except anyio.get_cancelled_exc_class():` branch at line 324 — a `BaseException` subclass, not caught by `except Exception` at line 328. That branch only logs and re-raises; it never touches `pending_control_responses`/`pending_control_results`. Nothing else in `_close_impl` signals them either.

**Result:** if a caller sends a control request (most commonly `interrupt()`, e.g. as part of a "stop" action) and then calls `close()`/`disconnect()` before the CLI has responded, `disconnect()` itself returns immediately (it doesn't wait on the pending request), but the coroutine awaiting `interrupt()` — or whichever request is in flight — is not signaled and sits blocked until `_send_control_request`'s own `anyio.fail_after(60.0)` fires, 60 seconds later, raising `Exception("Control request timeout: interrupt")`. For a caller that awaits `interrupt()` and `disconnect()` together (a natural pattern for a "stop the running turn" action), this looks like a hang, not a clean cancellation.

## Reproduction Steps / Example Code (Python)

Driven entirely through the public API (`ClaudeSDKClient` with a custom `Transport`, the SDK's own supported extension point — not a direct call into `_internal`). The fake transport acknowledges `initialize` immediately (so the client finishes connecting) but never responds to `interrupt`, simulating "the CLI hasn't answered yet" — exactly the window `close()`/`disconnect()` can land in during normal use.

```python
import anyio
from claude_agent_sdk import ClaudeSDKClient
from claude_agent_sdk._internal.transport import Transport

class FakeTransport(Transport):
"""Acks `initialize`, never responds to anything else (e.g. `interrupt`)."""

def __init__(self):
self._send, self._recv = anyio.create_memory_object_stream(10)
self.connected = False

async def connect(self) -> None:
self.connected = True

async def write(self, data: str) -> None:
import json
msg = json.loads(data)
if msg.get("type") == "control_request" and msg["request"].get("subtype") == "initialize":
await self._send.send({
"type": "control_response",
"response": {"subtype": "success", "request_id": msg["request_id"], "response": {}},
})
# anything else (e.g. "interrupt") is silently dropped -- no response ever sent

async def read_messages(self):
async for msg in self._recv:
yield msg

async def close(self) -> None:
self.connected = False

def is_ready(self) -> bool:
return self.connected

async def end_input(self) -> None:
pass

async def main():
client = ClaudeSDKClient(transport=FakeTransport())
await client.connect()

async def do_interrupt():
try:
with anyio.move_on_after(3.0) as scope:
await client.interrupt()
print("interrupt() still pending after 3s deadline:", scope.cancelled_caught)
except Exception as e:
print("interrupt() raised:", e)

async with anyio.create_task_group() as tg:
tg.start_soon(do_interrupt)
await anyio.sleep(0.05) # ensure interrupt() is genuinely in flight
import time
t0 = time.monotonic()
await client.disconnect()
print(f"disconnect() returned in {time.monotonic() - t0:.4f}s")

anyio.run(main)
```

## Actual output (current `main`, 3 runs, identical each time)

```
disconnect() returned in 0.0003s
interrupt() still pending after 3s deadline: True
```

Run with a longer deadline (65s) instead of 3s to show what eventually happens:

```
interrupt() raised: Control request timeout: interrupt
```
— firing at 60.00s, matching `_send_control_request`'s own `anyio.fail_after(60.0)` at line 532 exactly (confirmed via `time.monotonic()` around the call: 60.002s elapsed). This is the SDK's own eventual-timeout path, not an artifact of the test — `disconnect()` had already returned 60 seconds earlier.

**Expected:** `close()`/`disconnect()` should signal any still-pending outgoing control requests immediately (the same `pending_control_results[request_id] = ; event.set()` pattern #388 already added for the crash case, lines 329-333), so `interrupt()` (or whichever request was in flight) fails fast with a clear "connection closed" error instead of silently hanging for up to 60 seconds after the caller has already moved on.

## System Info

```
claude-agent-sdk-python, commit fdee0adc99f46e65ae9d6d029a6f4fb31bb8cffa (main, verified 2026-07-09/10)
python: 3.14, anyio backend (also reproduced on trio backend, same result)
```

Guía de contribución

No hay ninguna guía de contribución indexada para este repositorio

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.