query(): closing the iterator early leaves the CLI process running (inner process_query generator is never closed) · Issue #1377 · anthropics/claude-agent-sdk-python · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
query(): closing the iterator early leaves the CLI process running (inner process_query generator is never closed) #1377
query() wraps InternalClient.process_query() in a bare async for ... yield. When the caller stops early (break, or aclose() on the iterator), Python does not close the inner generator (PEP 533 was deferred), so process_query's finally (which calls Query.close() and terminates the subprocess) does not run. The CLI process stays alive until the interpreter exits (_kill_active_children at atexit). process_query itself guards against exactly this for its own inner generator (its comment cites PEP 533); the public wrapper does not.
Environment
claude-agent-sdk 0.1.81 and 0.2.164 (same code on main at f7b0b62, 2026-10-07)
Python 3.12.3, Linux, anyio 4.14.2
Reproduction (no API key needed)
The script writes a stand-in CLI that answers initialize and streams a few MB of assistant messages, then stops after the first message.
"""Closing a query() iterator early leaves the CLI process running.Self-contained: writes a stand-in CLI that answers the initialize handshake andstreams a few MB of assistant messages, so no API key or network is needed."""importasyncio, os, stat, sys, tempfile, textwrapfromclaude_agent_sdkimportClaudeAgentOptions, queryFAKE_CLI=textwrap.dedent(f"""\ #!{sys.executable} import json, os, sys open(os.environ["FAKE_PIDFILE"], "w").write(str(os.getpid())) def out(o): sys.stdout.write(json.dumps(o) + "\\n"); sys.stdout.flush() for line in sys.stdin: msg = json.loads(line) if msg.get("type") == "control_request": out({{"type": "control_response", "response": {{"subtype": "success", "request_id": msg["request_id"], "response": {{}}}}}}) elif msg.get("type") == "user": for _ in range(200): out({{"type": "assistant", "parent_tool_use_id": None, "session_id": "s", "message": {{"role": "assistant", "model": "fake", "content": [{{"type": "text", "text": "x" * 20000}}]}}}}) out({{"type": "result", "subtype": "success", "duration_ms": 1, "duration_api_ms": 1, "is_error": False, "num_turns": 1, "session_id": "s", "total_cost_usd": 0}})""")
defalive(pid: int) ->bool:
try:
os.kill(pid, 0)
returnTrueexceptProcessLookupError:
returnFalseasyncdefmain(cli: str, pidfile: str) ->int:
asyncfor_messageinquery(prompt="hi", options=ClaudeAgentOptions(cli_path=cli)):
break# stop after the first messagepid=int(open(pidfile).read())
awaitasyncio.sleep(2)
print(f"CLI pid {pid} alive 2 s after break: {alive(pid)}", flush=True)
returnpidwithtempfile.TemporaryDirectory() astmp:
cli=os.path.join(tmp, "claude")
withopen(cli, "w") asf:
f.write(FAKE_CLI)
os.chmod(cli, os.stat(cli).st_mode|stat.S_IEXEC)
os.environ["FAKE_PIDFILE"] =os.path.join(tmp, "pid")
os.environ["CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"] ="1"pid=asyncio.run(main(cli, os.environ["FAKE_PIDFILE"]))
print(f"CLI pid {pid} alive after asyncio.run() returned: {alive(pid)}", flush=True)
Output, 3 of 3 runs on each version:
CLI pid … alive 2 s after break: True
CLI pid … alive after asyncio.run() returned: True
Exception ignored in: <function BaseSubprocessTransport.__del__ …>
RuntimeError: Event loop is closed
Expected
Leaving the async for early (or aclose()) terminates the CLI, as exhausting the iterator does.
Suggested fix (verified against the repro: the CLI is terminated)
Two related defects visible once that fix is applied
SubprocessCLITransport.close() drops _stdout_stream without reading it to EOF or closing it. With output still unread, the asyncio subprocess transport stays open and is finalized after the loop closes, printing RuntimeError: Event loop is closed. Reading stdout to EOF after the process exits removed it in the same repro.
Query._read_messages iterates self.transport.read_messages() with async for; cancelling the read task in Query.close() leaves _read_messages_impl unclosed. When the loop later finalizes it, _read_messages_impl catches GeneratorExit, awaits the process, and raises ProcessError (exit -15 after close()'s SIGTERM), logged as "Task exception was never retrieved".
Possibly related upstream: #983 (also a teardown error raised from an async_generator_athrow task), #889 (grandchild processes not reaped).
Confirming on 0.2.164 and 0.2.165 (same query() body), with production impact from a long-running server.
We run query() inside an HTTP streaming gateway. Every consumer that stopped early, such as an SSE client disconnecting or an exception raised from the loop body (we raise on a rejected RateLimitEvent), left its CLI subprocess running. Over 42 hours one gateway process accumulated 57 orphaned claude processes at about 210 MB each, roughly 12 GB, which starved the host. The atexit reaper doesn't help a process that never exits.
contextlib.aclosing(query(...)) is not enough on the caller side, because closing query() stops at its yield and never reaches process_query's finally. Until a fix lands, we drive InternalClient().process_query(...) under aclosing directly. That reaps the subprocess before aclose() returns, but it depends on a private symbol. Either #1323 or #1378 would let us go back to query().
Activity
akhil9tiet commentedon Oct 12, 2026
I'd like to work on this — please assign it to me. I'll open a PR shortly.
fchazot commentedon Oct 12, 2026
Confirming on 0.2.164 and 0.2.165 (same
query()body), with production impact from a long-running server.We run
query()inside an HTTP streaming gateway. Every consumer that stopped early, such as an SSE client disconnecting or an exception raised from the loop body (we raise on a rejectedRateLimitEvent), left its CLI subprocess running. Over 42 hours one gateway process accumulated 57 orphanedclaudeprocesses at about 210 MB each, roughly 12 GB, which starved the host. Theatexitreaper doesn't help a process that never exits.contextlib.aclosing(query(...))is not enough on the caller side, because closingquery()stops at itsyieldand never reachesprocess_query'sfinally. Until a fix lands, we driveInternalClient().process_query(...)underaclosingdirectly. That reaps the subprocess beforeaclose()returns, but it depends on a private symbol. Either #1323 or #1378 would let us go back toquery().