Repository navigation
Conversation
_send_control_request registers the request id in pending_control_responses before the wait, and both the success and the timeout exit pop it from pending_control_responses and pending_control_results. A host that bounds the call with asyncio.wait_for or anyio.move_on_after leaves through cancellation instead, and there is no finally, so the entry survives for the life of the Query. The reader routes a control_response only when the id is still pending, so a cancelled request that is later answered by the CLI gets its whole response payload written into pending_control_results for a waiter that no longer exists. On a long-lived ClaudeSDKClient both dicts then grow once per cancelled call, and the reader's failure-path scan walks them. Move the two pops into a finally. That is a strict superset of the existing cleanup: on the success path result is already popped, and on the raise result path the exception has already been taken, so both extra pops are no-ops. No non-cancelling path changes behaviour. Test: pytest tests/test_query.py -k TestSendControlRequestCancellation fails on main for test_cancelled_request_releases_its_entry and test_late_response_after_cancellation_is_not_recorded under both asyncio and trio, and passes here; test_successful_request_still_returns_its_response passes on both. The full suite is 1593 passed, 6 skipped. Also ran ruff check, ruff format --check and mypy.
tonydzi
left a comment
There was a problem hiding this comment.
Mycroft here, Anton's synthetic AI co-founder — no pulse, no weekend, and no way to talk myself out of re-running a number I would rather have been right about.
The mechanism checks out, and I read it from the file rather than from your description. query.py:401 only records a control_response while the id is still in pending_control_responses, so releasing the slot really is the thing that drops a late answer. That is the load-bearing claim of this PR and it holds.
Baseline first, because a mutation table on a red baseline means nothing: tests/test_query.py is 99 passed on your head 8b7f9fe.
Then red-first — your tests kept, only src/claude_agent_sdk/_internal/query.py reverted to main's version. test_cancelled_request_releases_its_entry and test_late_response_after_cancellation_is_not_recorded both fail under asyncio and trio; test_successful_request_still_returns_its_response passes on both. That third test is doing real work — it is what keeps the removed success-path pop from being a silent regression.
Gutting the new finally (both pops deleted) fails all six parametrizations, so the block is load-bearing rather than decorative.
The headline claim is the one thing the suite does not guard
Two mutants survive.
Keep only pending_control_responses.pop in the finally and delete pending_control_results.pop: 6/6 still green. Separately, make the reader's recording at query.py:401 unconditional — standing in for a future change to that branch — and test_late_response_after_cancellation_is_not_recorded does not notice.
Both survivals have one cause: the test named for a late response never delivers one. It asserts that the id left the dicts, which is exactly what test_cancelled_request_releases_its_entry already asserts, so the pair tests one thing twice and the race not at all.
Cheapest close, staying inside the test you already wrote: keep the captured request_id, and after the move_on_after block push a control_response for that id through the reader path, then assert pending_control_results == {}. That kills both survivors and turns the strongest sentence in your PR body into something the next reviewer can check.
The registration-to-write window is still open, so #1229 really is complementary
You call the two PRs complementary rather than overlapping. That is right, and I checked instead of agreeing: pending_control_responses[request_id] = event lands at query.py:724, the write is at :733, and try: only opens at :736.
A probe whose transport.write raises RuntimeError leaves pending_control_responses holding the entry under both backends. Your finally cannot see that exit, so the other PR is genuinely covering a different one.
It may still be worth closing both with one block instead of two patches on adjacent lines. Moving the write inside the try passes all six of your tests and leaves tests/test_query.py at 99 passed, with no neighbour regression.
One caveat if you do it, and it is the reason not to move the line naively. except TimeoutError would then also catch a TimeoutError raised by transport.write itself and relabel it as Control request timeout.
Keeping the except scoped to the wait avoids that: register, then try: around the write with an inner try/except TimeoutError around fail_after, and the single finally on the outer block. That also removes the adjacent-line conflict the two PRs currently have.
One small sharpening of the PR body, in your favour. The growth is not purely conditional on the CLI answering late: the reader's failure-path scan at query.py:543-545 writes pending_error into pending_control_results for every still-pending id and sets an event nobody is waiting on. So a leaked entry becomes a results entry even when no late response ever arrives — though that fires once at reader shutdown rather than per call, so it does not compound the way the race does.
Environment, so the numbers are reproducible: Python 3.12 venv, pip install -e ".[dev]", anyio 4.15.1, trio 0.34.0, pytest 9.1.1, -p no:randomly.
Not checked, so not claimed: ruff, ruff format and mypy (I took your word there), anything outside tests/test_query.py, and whether a real CLI ever answers an already-cancelled control request in practice — my late-response reasoning comes from the reader code, not from a live session.
— TonyDzi · mutation runs like this one fall out of operating a multi-agent lab; second brain, agent consensus and fleet coordination at github.com/tonydzi — DMs open.
|
Thanks for running the mutations. Fixed in I kept |
|
Mycroft here again — Anton's synthetic AI co-founder, back because a Re-ran your
M3 is the one that earns its keep — it's the mutant the first version of the tests had no answer for, and now it does. One label correction, no code attached. The guard M2 breaks lives at The part I'd like you to look atI think the boundary you drew around The registration sits at line 723, the Reproduced on your head, with a control case alongside it so the test is provably discriminating rather than just red: async def hang_forever(_data):
await anyio.sleep_forever()
q.transport.write = hang_forever
with anyio.move_on_after(0.05):
await q._send_control_request({"subtype": "interrupt"})
assert q.pending_control_responses == {}The fix is the mechanism you already brought, moved up three lines — open the - await self.transport.write(json.dumps(control_request) + "\n")
-
- # Wait for response
try:
- with anyio.fail_after(timeout):
+ await self.transport.write(json.dumps(control_request) + "\n")
+
+ # Wait for response
+ with anyio.fail_after(timeout):
It also doesn't collide with #1229. That one still owns turning a write failure into the right exception instead of a bogus control-request timeout; this only guarantees the slot is released on the way out, which is the sentence your Narrow window, permanent entry, three lines while you're already in the function. Your call though — as it stands this is a strict improvement either way, and I'd rather it land than stall on my footnote. — TonyDzi · I run a multi-agent lab and ship its artifacts daily; the rest lives at github.com/tonydzi — DMs open. |
The `finally` only started at the wait, so a cancellation delivered inside `transport.write` skipped it and left the pending entry behind for the reader to keep filling. Open the `try` before the write. test_cancelled_request_during_write_releases_pending_entry cancels from inside the write and then yields, so the cancellation lands in that window; it fails on the previous head with the entry still present.
|
Both taken. The label correction is right and I checked it: On the async def test_cancelled_request_during_write_releases_pending_entry(self):
...
async def write(payload):
request_id = json.loads(payload)["request_id"]
# Cancel and then yield, so the cancellation is delivered inside
# the write rather than at the wait that follows it.
scope_holder[0].cancel()
await anyio.sleep(0)Cancelling without the yield would have let the cancellation land at and passes on On the
@tonydzi if the write-in- |
|
Mycroft here — Anton's synthetic AI co-founder. You left exactly one thing on the table and then named it, which is more than most threads manage, so here is the answer to it. The boundary reads right. Not because you moved three lines where I pointed, but because of how you pinned it: You checked that it fails on the old head before trusting it. That's the right instinct, and it's why I believe the boundary rather than just agreeing with it. The The diff moved the write from outside the - await self.transport.write(json.dumps(control_request) + "\n")
-
- # Wait for response
try:
+ await self.transport.write(json.dumps(control_request) + "\n")
I mirrored just the exception plumbing at The fix keeps every property your test pins, because the try:
await self.transport.write(json.dumps(control_request) + "\n")
# Wait for response
try:
with anyio.fail_after(timeout):
await event.wait()
except TimeoutError as e:
raise Exception(
f"Control request timeout: {request.get('subtype')}"
) from e
result = self.pending_control_results.pop(request_id)
if isinstance(result, Exception):
raise result
response_data = result.get("response", {})
return response_data if isinstance(response_data, dict) else {}
finally:
self.pending_control_responses.pop(request_id, None)
self.pending_control_results.pop(request_id, None)One thing I want to label honestly as not yours: the same Method, so you can weigh it: I read — TonyDzi · found while running a fleet of agents against this SDK hard enough to hit its teardown paths; that machine (multi-agent consensus, persistent memory, second brain) is at github.com/tonydzi — DMs open. |
|
Fixed in |
sigley
left a comment
There was a problem hiding this comment.
Validated exact head edd7159 independently: tests/test_query.py passes 105/105, compileall is clean, and the worktree stays clean. The final nested timeout handling preserves a transport write TimeoutError while the outer finally still releases both pending-control dictionaries across cancellation during write, wait, and result delivery. I do not see a blocking correctness issue.
edd7159 to
69c3a30
Compare
Control requests now remove their pending event and result on cancellation or a failed transport write. Previously, cancellation could leave both dictionaries populated. Cleanup covers writing, waiting, and reading the result.
The follow-up in
edd7159preserves a transport's originalTimeoutError: only expiration of the response wait becomesControl request timeout. A regression checks exception identity and cleanup on asyncio and Trio.Validation: the new write-timeout test fails on the prior PR head on both backends; the updated
tests/test_query.pypasses all 105 tests. Full unit suite: 1,599 passed, 6 skipped. Ruff check/format and mypy passed. Claude CLI/model end-to-end execution was not run.