Handle control-channel write failures without corrupting Query state - #1229
Open
venkat-uk wants to merge 1 commit into
Open
Handle control-channel write failures without corrupting Query state#1229venkat-uk wants to merge 1 commit into
venkat-uk wants to merge 1 commit into
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.
venkat-uk
force-pushed
the
fix/control-channel-write-failures
branch
from
August 22, 2026 13:16
7d40428 to
f39bcb3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.