Description
query() with a string prompt and PreToolUse hooks deadlocks once the internal 100 message buffer fills up. Each tool call puts ~2 messages into the buffer, so around 50 tool calls is enough to trigger it.
Hooked tools (Bash, Skill) getting denied after the CLI times out on hook callbacks. But the real problem is that the whole session stalls.
Root cause
For string prompts, _internal/client.py:141 awaits wait_for_result_and_end_input() before receive_messages() starts:
# _internal/client.py:130-147
if isinstance(prompt, str):
await chosen_transport.write(json.dumps(user_message) + "\n")
await query.wait_for_result_and_end_input() # blocks until "result" arrives
async for data in query.receive_messages(): # buffer drain starts here
Meanwhile _read_messages() keeps reading from CLI stdout and pushing into the 100 slot anyio channel (query.py:107-109). After ~50 tool calls the channel is full and _message_send.send() at line 236 blocks. Now _read_messages can't read anything else from stdout, including the "result" message that wait_for_result_and_end_input needs.
The _read_messages finally block (query.py:254) does call _first_result_event.set(), but it never runs because we're stuck inside the try. And even if it ran, it also tries to send() to the same full buffer.
For exmaple, the AsyncIterable path avoids all of this because it spawns stream_input() as a background task (client.py:144), so receive_messages() starts immediately.
Reproduction
Note: Use a big file instead of test.py
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher, ResultMessage, query
async def allow_hook(input_data, tool_use_id, ctx):
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "allowed",
}}
async def main():
steps = [f"{i}. Read file test.py from line {(i-1)*15+1} with limit 15" for i in range(1, 55)]
steps.append("55. Run Bash command: pwd")
prompt = "Do ONE tool call per step, never parallel:\n" + "\n".join(steps)
async for message in query(
prompt=prompt,
options=ClaudeAgentOptions(
model="claude-opus-4-6",
permission_mode="dontAsk",
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[allow_hook])]},
),
):
if isinstance(message, ResultMessage):
print(message.result)
asyncio.run(main())
Expected: all 55 steps complete including pwd.
Actual: after ~50 Reads the session deadlocks. CLI times out on the Bash hook and falls back to dontAsk denial: "Permission to use Bash has been denied because Claude Code is running in don't ask mode."
Workaround
Bump the queue size
import anyio
from claude_agent_sdk._internal import query as _query_mod
_orig_init = _query_mod.Query.__init__
def _patched_init(self, *args, **kwargs):
_orig_init(self, *args, **kwargs)
self._message_send, self._message_receive = anyio.create_memory_object_stream[dict](
max_buffer_size=10_000,
)
_query_mod.Query.__init__ = _patched_init
Suggested fix
Spawn wait_for_result_and_end_input() as a background task for string prompts, same as the AsyncIterable path already does:
if isinstance(prompt, str):
await chosen_transport.write(json.dumps(user_message) + "\n")
query.spawn_task(query.wait_for_result_and_end_input())
Environment
- claude-agent-sdk 0.1.52
- Bundled CLI 2.1.87
- Python 3.12
- anyio (latest)
Description
query()with a string prompt and PreToolUse hooks deadlocks once the internal 100 message buffer fills up. Each tool call puts ~2 messages into the buffer, so around 50 tool calls is enough to trigger it.Hooked tools (Bash, Skill) getting denied after the CLI times out on hook callbacks. But the real problem is that the whole session stalls.
Root cause
For string prompts,
_internal/client.py:141awaitswait_for_result_and_end_input()beforereceive_messages()starts:Meanwhile
_read_messages()keeps reading from CLI stdout and pushing into the 100 slot anyio channel (query.py:107-109). After ~50 tool calls the channel is full and_message_send.send()at line 236 blocks. Now_read_messagescan't read anything else from stdout, including the "result" message thatwait_for_result_and_end_inputneeds.The
_read_messagesfinally block (query.py:254) does call_first_result_event.set(), but it never runs because we're stuck inside thetry. And even if it ran, it also tries tosend()to the same full buffer.For exmaple, the AsyncIterable path avoids all of this because it spawns
stream_input()as a background task (client.py:144), soreceive_messages()starts immediately.Reproduction
Note: Use a big file instead of test.py
Expected: all 55 steps complete including
pwd.Actual: after ~50 Reads the session deadlocks. CLI times out on the Bash hook and falls back to dontAsk denial: "Permission to use Bash has been denied because Claude Code is running in don't ask mode."
Workaround
Bump the queue size
Suggested fix
Spawn
wait_for_result_and_end_input()as a background task for string prompts, same as the AsyncIterable path already does:Environment