You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Pending outgoing control requests (interrupt, set_permission_mode, etc.) hang for the full 60s timeout after close()/disconnect() instead of failing fast #1094
_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):
But _read_messages has an earlier, separate handler for cancellation:
# lines 324-327exceptanyio.get_cancelled_exc_class():
# Task was cancelled - this is expected behaviorlogger.debug("Read task cancelled")
raise# Re-raise to properly handle cancellationexceptExceptionase:
# Signal all pending control requests so they fail fast instead of timing outforrequest_id, eventinlist(self.pending_control_responses.items()):
...
close() → _close_impl() (lines 886-907) cancels the read task directly:
# from _close_implifself._read_taskisnotNoneandnotself._read_task.done():
self._read_task.cancel()
awaitself._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.
importanyiofromclaude_agent_sdkimportClaudeSDKClientfromclaude_agent_sdk._internal.transportimportTransportclassFakeTransport(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=Falseasyncdefconnect(self) ->None:
self.connected=Trueasyncdefwrite(self, data: str) ->None:
importjsonmsg=json.loads(data)
ifmsg.get("type") =="control_request"andmsg["request"].get("subtype") =="initialize":
awaitself._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 sentasyncdefread_messages(self):
asyncformsginself._recv:
yieldmsgasyncdefclose(self) ->None:
self.connected=Falsedefis_ready(self) ->bool:
returnself.connectedasyncdefend_input(self) ->None:
passasyncdefmain():
client=ClaudeSDKClient(transport=FakeTransport())
awaitclient.connect()
asyncdefdo_interrupt():
try:
withanyio.move_on_after(3.0) asscope:
awaitclient.interrupt()
print("interrupt() still pending after 3s deadline:", scope.cancelled_caught)
exceptExceptionase:
print("interrupt() raised:", e)
asyncwithanyio.create_task_group() astg:
tg.start_soon(do_interrupt)
awaitanyio.sleep(0.05) # ensure interrupt() is genuinely in flightimporttimet0=time.monotonic()
awaitclient.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] = <some cancelled/closed exception>; 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)
Related Issues / PRs
query.py, theexcept 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 gracefulclose()/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 theclose()/disconnect()counterpart to fix: propagate CLI errors to pending control requests #388.Description
Query(src/claude_agent_sdk/_internal/query.py) tracks in-flight outgoing control requests (wire subtypesinterrupt,set_permission_mode,set_model,rewind_files,mcp_reconnect,mcp_toggle,stop_task,mcp_status,get_context_usage,initialize) in two dicts:_send_control_request(lines 501-546) registers ananyio.Eventfor the request (line 519) and waits on it underanyio.fail_after(timeout)(default 60s):There are exactly two places that set the event and unblock this wait:
_read_messages, when acontrol_responsemessage arrives (lines 260-268,event.set()at line 268).except Exception as e:(line 328) in_read_messages.But
_read_messageshas an earlier, separate handler for cancellation:close()→_close_impl()(lines 886-907) cancels the read task directly:This makes the read task raise
anyio.get_cancelled_exc_class(), which is caught by theexcept anyio.get_cancelled_exc_class():branch at line 324 — aBaseExceptionsubclass, not caught byexcept Exceptionat line 328. That branch only logs and re-raises; it never touchespending_control_responses/pending_control_results. Nothing else in_close_implsignals them either.Result: if a caller sends a control request (most commonly
interrupt(), e.g. as part of a "stop" action) and then callsclose()/disconnect()before the CLI has responded,disconnect()itself returns immediately (it doesn't wait on the pending request), but the coroutine awaitinginterrupt()— or whichever request is in flight — is not signaled and sits blocked until_send_control_request's ownanyio.fail_after(60.0)fires, 60 seconds later, raisingException("Control request timeout: interrupt"). For a caller that awaitsinterrupt()anddisconnect()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 (
ClaudeSDKClientwith a customTransport, the SDK's own supported extension point — not a direct call into_internal). The fake transport acknowledgesinitializeimmediately (so the client finishes connecting) but never responds tointerrupt, simulating "the CLI hasn't answered yet" — exactly the windowclose()/disconnect()can land in during normal use.Actual output (current
main, 3 runs, identical each time)Run with a longer deadline (65s) instead of 3s to show what eventually happens:
— firing at 60.00s, matching
_send_control_request's ownanyio.fail_after(60.0)at line 532 exactly (confirmed viatime.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 samepending_control_results[request_id] = <some cancelled/closed exception>; event.set()pattern #388 already added for the crash case, lines 329-333), sointerrupt()(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