Conversation
…g prompts For string prompts with hooks or SDK MCP servers, query() awaited wait_for_result_and_end_input() before receive_messages() started draining the buffer. Once the 100-slot anyio channel filled (~50 tool calls), _read_messages blocked on send() and could never deliver the result message that wait_for_result_and_end_input needed, causing a deadlock. Spawn it as a background task instead, matching the existing AsyncIterable path which already uses spawn_task(stream_input()). Fixes #779
hackyon-anthropic
approved these changes
Mar 30, 2026
Contributor
Author
E2E Test ResultsTest script: #!/usr/bin/env python3
"""E2E proof for PR #780: verify query() with string prompt + MCP doesn't deadlock.
The fix spawns wait_for_result_and_end_input() as a background task so the
message buffer drains concurrently, preventing deadlock after many tool calls.
This test creates an MCP server with a simple tool and asks the model to call
it many times sequentially. Each tool call generates multiple messages through
the buffer (assistant message, tool result, progress updates). Without the fix,
wait_for_result_and_end_input() is awaited inline which blocks _read_messages()
from draining the buffer -- causing a deadlock once the 100-slot anyio buffer
fills up.
Prior to this fix, running query() with a string prompt + MCP server would
hang after enough tool calls. With the fix, the wait is moved to a background
task so messages drain concurrently.
"""
import asyncio
import sys
import time
from typing import Any
import claude_agent_sdk
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
SystemMessage,
TextBlock,
ToolUseBlock,
UserMessage,
create_sdk_mcp_server,
tool,
)
@tool("get_number", "Return the square of the given number", {"n": int})
async def get_number(args: dict[str, Any]) -> dict[str, Any]:
"""Return the square of a number."""
n = args.get("n", 0)
return {"content": [{"type": "text", "text": str(n * n)}]}
async def main() -> None:
print("=" * 70)
print("PR #780 E2E Test: string-prompt + MCP deadlock fix (many tool calls)")
print("=" * 70)
print()
print(f"SDK version: {claude_agent_sdk.__version__}")
mcp_server = create_sdk_mcp_server(
name="math_server",
version="1.0.0",
tools=[get_number],
)
options = ClaudeAgentOptions(
mcp_servers={"math": mcp_server},
max_turns=30,
permission_mode="acceptEdits",
)
# Request 20 sequential tool calls. The model will delegate to sub-agent(s)
# which each make many individual MCP tool calls. The messages generated by
# these calls (assistant messages, tool results, progress updates) all flow
# through the same anyio buffer that would deadlock without the fix.
prompt = (
"Use the get_number MCP tool to compute the square of every integer from 1 to 20. "
"Make exactly 20 individual get_number calls (one per integer). "
"After all calls complete, list every input and result."
)
print(f"Prompt: {prompt[:120]}...")
print(f"Max turns: {options.max_turns}")
print(f"Permission mode: {options.permission_mode}")
print(f"Timeout: 180s")
print()
print("--- Running query() ---")
tool_calls = 0
total_messages = 0
msg_types: dict[str, int] = {}
result_msg = None
t0 = time.monotonic()
try:
async with asyncio.timeout(180):
async for message in claude_agent_sdk.query(
prompt=prompt,
options=options,
):
total_messages += 1
mtype = type(message).__name__
msg_types[mtype] = msg_types.get(mtype, 0) + 1
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
tool_calls += 1
if block.name == "mcp__math__get_number":
print(f" Tool #{tool_calls}: get_number(n={block.input.get('n', '?')})")
else:
desc = str(block.input)[:100]
print(f" Tool #{tool_calls}: {block.name}({desc}...)")
elif isinstance(message, ResultMessage):
result_msg = message
except TimeoutError:
elapsed = time.monotonic() - t0
print()
print(f"FAIL: Timed out after {elapsed:.1f}s -- likely deadlock!")
print("This is what happened BEFORE the fix was applied.")
sys.exit(1)
except Exception as e:
elapsed = time.monotonic() - t0
print()
print(f"FAIL: Exception after {elapsed:.1f}s: {e}")
sys.exit(1)
elapsed = time.monotonic() - t0
print()
print("-" * 70)
print(f"Completed in {elapsed:.1f}s")
print(f"Total messages: {total_messages}")
print(f"Tool calls: {tool_calls}")
print(f"Message breakdown: {msg_types}")
if result_msg:
print(f"Cost: ${result_msg.total_cost_usd or 0:.6f}")
print(f"Turns: {result_msg.num_turns}")
print()
if result_msg is not None and tool_calls >= 1:
print(f"PASS: query() with string prompt + MCP completed {tool_calls} tool")
print(f" call(s) generating {total_messages} buffered messages without")
print(f" deadlocking ({elapsed:.1f}s). The background-task fix works.")
elif result_msg is not None:
print(f"PASS: query() completed without deadlock ({total_messages} messages).")
else:
print("FAIL: No result message received.")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())Output: Verified: |
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.
Problem
query()with a string prompt and hooks/MCP servers deadlocks once the internal 100-slot anyio message buffer fills up (~50 tool calls). Each tool call produces ~2 messages, so the buffer fills after about 50 tool calls.Root cause
For string prompts,
client.py:141awaitedwait_for_result_and_end_input()beforereceive_messages()started draining the buffer:Meanwhile
_read_messages()keeps reading CLI stdout and pushing into the 100-slot channel. After ~50 tool calls the channel is full and_message_send.send()blocks. Now_read_messagescan't read anything else from stdout, including the "result" message thatwait_for_result_and_end_inputneeds — deadlock.Fix
Spawn
wait_for_result_and_end_input()as a background task instead of awaiting it inline. This matches the existingAsyncIterablepath which already usesspawn_task(stream_input()), and allowsreceive_messages()to start draining the buffer immediately.Testing
spawn_taskis called instead of directawaitFixes #779