Skip to content

Commit 5bc06c7

Browse files
fix: Resolve RuntimeError on async generator cleanup
This fixes the "Attempted to exit cancel scope in a different task than it was entered in" error that occurs when sequential query() calls are made. The fix uses two mechanisms: 1. A CancelScope for the reader task that can be cancelled from any context 2. Suppressing the RuntimeError that occurs when task group __aexit__() is called from a different task than __aenter__() Also adds aclosing() wrapper and GeneratorExit handling in process_query() for proper async generator cleanup. Based on PR anthropics#527: anthropics#527 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5f0dcbd commit 5bc06c7

2 files changed

Lines changed: 103 additions & 10 deletions

File tree

src/claude_agent_sdk/_internal/client.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Internal client implementation."""
22

3+
import logging
34
from collections.abc import AsyncIterable, AsyncIterator
5+
from contextlib import aclosing
46
from dataclasses import replace
57
from typing import Any
68

@@ -15,6 +17,8 @@
1517
from .transport import Transport
1618
from .transport.subprocess_cli import SubprocessCLITransport
1719

20+
logger = logging.getLogger(__name__)
21+
1822

1923
class InternalClient:
2024
"""Internal client implementation."""
@@ -117,8 +121,14 @@ async def process_query(
117121
# For string prompts, the prompt is already passed via CLI args
118122

119123
# Yield parsed messages
120-
async for data in query.receive_messages():
121-
yield parse_message(data)
122-
124+
# Use aclosing() for proper async generator cleanup
125+
async with aclosing(query.receive_messages()) as messages:
126+
async for data in messages:
127+
yield parse_message(data)
128+
129+
except GeneratorExit:
130+
# Handle early termination of the async generator gracefully
131+
# This occurs when the caller breaks out of the async for loop
132+
logger.debug("process_query generator closed early by caller")
123133
finally:
124134
await query.close()

src/claude_agent_sdk/_internal/query.py

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import TYPE_CHECKING, Any
99

1010
import anyio
11+
from anyio.abc import CancelScope
1112
from mcp.types import (
1213
CallToolRequest,
1314
CallToolRequestParams,
@@ -113,6 +114,15 @@ def __init__(
113114
float(os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")) / 1000.0
114115
) # Convert ms to seconds
115116

117+
# Cancel scope for the reader task - can be cancelled from any task context
118+
# This fixes the RuntimeError when async generator cleanup happens in a different task
119+
self._reader_cancel_scope: CancelScope | None = None
120+
self._reader_task_started = anyio.Event()
121+
122+
# Track whether we entered the task group in this task
123+
# Used to determine if we can safely call __aexit__()
124+
self._tg_entered_in_current_task = False
125+
116126
async def initialize(self) -> dict[str, Any] | None:
117127
"""Initialize control protocol if in streaming mode.
118128
@@ -158,11 +168,33 @@ async def initialize(self) -> dict[str, Any] | None:
158168
return response
159169

160170
async def start(self) -> None:
161-
"""Start reading messages from transport."""
171+
"""Start reading messages from transport.
172+
173+
This method starts background tasks for reading messages. The task lifecycle
174+
is managed using a CancelScope that can be safely cancelled from any async
175+
task context, avoiding the RuntimeError that occurs when task group
176+
__aexit__() is called from a different task than __aenter__().
177+
"""
162178
if self._tg is None:
179+
# Create a task group for spawning background tasks
163180
self._tg = anyio.create_task_group()
164181
await self._tg.__aenter__()
165-
self._tg.start_soon(self._read_messages)
182+
self._tg_entered_in_current_task = True
183+
184+
# Start the reader with its own cancel scope that can be cancelled safely
185+
self._tg.start_soon(self._read_messages_with_cancel_scope)
186+
187+
async def _read_messages_with_cancel_scope(self) -> None:
188+
"""Wrapper for _read_messages that sets up a cancellable scope.
189+
190+
This wrapper creates a CancelScope that can be cancelled from any task
191+
context, solving the issue where async generator cleanup happens in a
192+
different task than where the task group was entered.
193+
"""
194+
self._reader_cancel_scope = anyio.CancelScope()
195+
self._reader_task_started.set()
196+
with self._reader_cancel_scope:
197+
await self._read_messages()
166198

167199
async def _read_messages(self) -> None:
168200
"""Read messages from transport and route them."""
@@ -604,15 +636,66 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]:
604636
yield message
605637

606638
async def close(self) -> None:
607-
"""Close the query and transport."""
639+
"""Close the query and transport.
640+
641+
This method safely cleans up resources, handling the case where cleanup
642+
happens in a different async task context than where start() was called.
643+
This commonly occurs during async generator cleanup (e.g., when breaking
644+
out of an `async for` loop or when asyncio.run() shuts down).
645+
646+
The fix uses two mechanisms:
647+
1. A CancelScope for the reader task that can be cancelled from any context
648+
2. Suppressing the RuntimeError that occurs when task group __aexit__()
649+
is called from a different task than __aenter__()
650+
"""
651+
if self._closed:
652+
return
608653
self._closed = True
609-
if self._tg:
654+
655+
# Cancel the reader task via its cancel scope (safe from any task context)
656+
if self._reader_cancel_scope is not None:
657+
self._reader_cancel_scope.cancel()
658+
659+
# Handle task group cleanup
660+
if self._tg is not None:
661+
# Always cancel the task group's scope to stop any running tasks
610662
self._tg.cancel_scope.cancel()
611-
# Wait for task group to complete cancellation
612-
with suppress(anyio.get_cancelled_exc_class()):
613-
await self._tg.__aexit__(None, None, None)
663+
664+
# Try to properly exit the task group, but handle the case where
665+
# we're in a different task context than where __aenter__() was called
666+
try:
667+
with suppress(anyio.get_cancelled_exc_class()):
668+
await self._tg.__aexit__(None, None, None)
669+
except RuntimeError as e:
670+
# Handle "Attempted to exit cancel scope in a different task"
671+
# This happens during async generator cleanup when Python's GC
672+
# runs the finally block in a different task context.
673+
if "different task" in str(e):
674+
logger.debug(
675+
"Task group cleanup skipped due to cross-task context "
676+
"(this is expected during async generator cleanup)"
677+
)
678+
else:
679+
raise
680+
finally:
681+
self._tg = None
682+
self._tg_entered_in_current_task = False
683+
614684
await self.transport.close()
615685

686+
# Make Query an async context manager
687+
async def __aenter__(self) -> "Query":
688+
"""Enter async context - starts reading messages."""
689+
await self.start()
690+
return self
691+
692+
async def __aexit__(
693+
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any
694+
) -> bool:
695+
"""Exit async context - closes the query."""
696+
await self.close()
697+
return False
698+
616699
# Make Query an async iterator
617700
def __aiter__(self) -> AsyncIterator[dict[str, Any]]:
618701
"""Return async iterator for messages."""

0 commit comments

Comments
 (0)