Repository navigation
Conversation
Query writes to the CLI on both directions of the control channel, and neither write is guarded. _handle_control_request runs in a detached task and answers every hook, permission and SDK-MCP request. If the CLI has exited, or stdin has been closed, while one of those was in flight, the response write raises and the exception leaves the task: trio logs "Unhandled exception in detached trio task" with a traceback, asyncio logs "Task exception was never retrieved". Nothing is actually broken, but the logs read as an SDK crash during an ordinary shutdown race. Routing both writes through a helper also stops a failed success write from falling into the except clause, where it was answered with an error response for a request the handler had in fact completed. _send_control_request registers the waiter before writing, and the write sits outside the try that cleans up on timeout. A write that fails leaves the entry in pending_control_responses for the life of the Query, so a long-lived client whose interrupt() keeps failing grows both dicts.
7d40428 to
f39bcb3
Compare
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
The write-failure cleanup can still leave pending_control_results behind. A cancellation or write error can happen after bytes reached the CLI; if the response races in before this except pops pending_control_responses, _read_messages stores the result and sets the event. This path then removes only the response slot, leaving that result behind for the lifetime of the Query. Could we clean both dictionaries here and add a racing-response regression?
A write can fail or be cancelled after some bytes reached the CLI. If the CLI's response arrives before the except clause runs, _read_messages has already stored the result in pending_control_results and set the event. The cleanup only popped pending_control_responses, so that result stayed for the life of the Query. Pop both dicts, and add a regression test that drives the real read loop from inside write() so the response is routed before the write raises. The test fails without the extra pop.
|
Sorry for the slow reply. The cleanup after a failed or cancelled write now pops the request id from both I added I also merged current main into the branch. The full test suite, ruff and mypy pass locally. |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
Rechecked current b37bb268. This closes the race I flagged: if the CLI response arrives before the outbound write reports failure, _read_messages may populate pending_control_results, and the write-error cleanup now removes both the waiter and that stored result before re-raising. The new regression drives the response through the real _read_messages path before the write fails and verifies both dictionaries are empty afterward. Focused validation: 5/5 control-channel write-failure tests passed; Ruff clean; mypy clean for query.py; git diff --check clean. No remaining blocker from my review.
What changed
Querywrites to the CLI on both directions of the control channel. Neitherwrite was guarded, so a write that fails leaves the object in a bad state.
Two changes in
src/claude_agent_sdk/_internal/query.py:_handle_control_requestnow sends both its success and its error responsethrough a new
_send_control_response(), which logs a failed write at debugand returns instead of letting it escape.
_send_control_requestremoves the request's entry frompending_control_responseswhen the outgoing write fails, so no waiter isleft behind for a request the CLI never received.
Why
The detached task.
_handle_control_requestis spawned detached(
_spawn_control_request_handler) and answers every hook, permission andSDK-MCP request. If the CLI has exited, or stdin has been closed, while one of
those was in flight,
transport.write()raisesCLIConnectionErrorand theexception leaves the task. There is no caller to receive it, so it surfaces as
an unhandled task error: on trio,
_task_compat._mark_donelogsUnhandled exception in detached trio taskwith a full traceback; on asyncio itbecomes
Task exception was never retrievedat collection. Nothing is actuallybroken, but the logs read as an SDK crash during an ordinary shutdown race, and
_task_compattreats an exception escaping a detached task as a bug signal bydesign.
The error write is worse because it sits inside the
exceptclause, so afailure there also replaces the original error with the write error.
Routing both writes through one helper has a second effect. Before, a failed
success write fell into the
except Exceptionclause and the SDK then triedto send the CLI an error response for a request the handler had in fact
completed. Now a success write that fails is just a lost reply.
The pending entry.
_send_control_requestregisters the waiter, thenwrites:
The write is outside the
try, so only the timeout path cleans up. When thewrite itself fails the caller gets the error but the entry stays for the life of
the
Query.ClaudeSDKClientis long lived, so aninterrupt()orset_permission_mode()that keeps failing against a dead subprocess grows bothdicts, and the read loop's failure path later writes a result into
pending_control_resultsfor every one of those ghost ids.How tested
Four tests added in
tests/test_query.py(
TestControlChannelWriteFailures), using anAsyncMocktransport whosewrite()raises the sameCLIConnectionErrora terminated subprocess produces.Three of the four fail on
bc0c9afwithout the source change:The fourth (
test_cancelled_request_still_skips_the_response) passes either wayand is a regression guard:
except anyio.get_cancelled_exc_class(): raisemustkeep coming before the new
except Exception, so a cancelled request stillwrites nothing.
Full suite and the repo's own gates, on Python 3.12 with mcp 2.x:
Also checked by hand on both backends that the unhandled-task log is gone: a
Querywith a failing transport, given one hook request that succeeds and onewith an unknown callback id, produces the trio warning and the asyncio traceback
on
bc0c9afand neither after the change.Tradeoffs
DEBUG.That is a real loss of signal, but the transport failure itself still reaches
the consumer through the read loop, which is where it is actionable. Raising
from a detached task was never a way to tell anyone.
loop no longer resolves that id. Nothing waits on it, so there is nothing to
resolve.
_send_control_requestcatchesBaseExceptionaround the write so acancellation cleans up too, then re-raises. If a caller is cancelled exactly
between the write landing and the cleanup, the entry goes and a response that
does arrive is dropped by the read loop's
if request_id in ...guard. That isthe same outcome as the existing timeout path.
_send_control_responsecatchesException, notBaseException, so acancellation during the write still propagates and is handled by the existing
cancelled-request branch.