Skip to content

Commit 431b46d

Browse files
Xian Zhengclaude
andcommitted
fix(transcript_mirror): handle CancelledError in eager-flush done callback
The ``add_done_callback`` lambda on the eager-flush ``_flush_task`` called ``t.exception()`` unconditionally. In Python 3.8+, ``Task.exception()`` raises ``CancelledError`` for cancelled tasks, and the raise from inside a done-callback surfaces as a noisy "Exception in callback" log every time the SDK shuts down with pending eager flushes (visible in #928's failing-test traceback). Replace the lambda with a module-level ``_swallow_done_exception`` helper that no-ops on cancelled tasks and otherwise retrieves the exception so asyncio doesn't warn about an unretrieved exception on a fire-and-forget task. Closes #930 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9aafd84 commit 431b46d

2 files changed

Lines changed: 59 additions & 1 deletion

File tree

src/claude_agent_sdk/_internal/transcript_mirror_batcher.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@
3333
MIRROR_APPEND_BACKOFF_S = (0.2, 0.8)
3434

3535

36+
def _swallow_done_exception(t: asyncio.Task[None]) -> None:
37+
# Retrieve the task's exception (if any) so asyncio doesn't warn about an
38+
# unretrieved exception on a fire-and-forget task. Skip cancelled tasks:
39+
# Task.exception() raises CancelledError on those (Python 3.8+), and the
40+
# raise from inside a done-callback surfaces as a noisy "Exception in
41+
# callback" log on every cancellation.
42+
if t.cancelled():
43+
return
44+
t.exception()
45+
46+
3647
@dataclass
3748
class _MirrorEntry:
3849
file_path: str
@@ -88,7 +99,7 @@ def enqueue(self, file_path: str, entries: list[SessionStoreEntry]) -> None:
8899
# so append ordering holds. drain() never raises, but guard anyway
89100
# so a future regression can't surface as an unhandled exception.
90101
self._flush_task = asyncio.ensure_future(self._drain())
91-
self._flush_task.add_done_callback(lambda t: t.exception())
102+
self._flush_task.add_done_callback(_swallow_done_exception)
92103

93104
async def flush(self) -> None:
94105
"""Flush all pending entries. Awaits any in-flight eager flush first."""

tests/test_transcript_mirror.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
MAX_PENDING_BYTES,
3232
MAX_PENDING_ENTRIES,
3333
TranscriptMirrorBatcher,
34+
_swallow_done_exception,
3435
)
3536
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
3637

@@ -504,6 +505,52 @@ async def append(self, key, entries):
504505
assert order == [1, 2, 3]
505506

506507

508+
# ---------------------------------------------------------------------------
509+
# _swallow_done_exception
510+
# ---------------------------------------------------------------------------
511+
512+
513+
class TestSwallowDoneException:
514+
"""Regression for issue #930: the eager-flush task's done-callback used
515+
``lambda t: t.exception()``, which raises ``CancelledError`` for cancelled
516+
tasks (Python 3.8+) and surfaces as a noisy "Exception in callback" log
517+
every time the SDK shuts down with pending eager flushes.
518+
"""
519+
520+
@pytest.mark.asyncio
521+
async def test_returns_none_for_cancelled_task(self) -> None:
522+
async def _hang() -> None:
523+
await asyncio.sleep(3600)
524+
525+
task = asyncio.ensure_future(_hang())
526+
task.cancel()
527+
with pytest.raises(asyncio.CancelledError):
528+
await task
529+
assert task.cancelled()
530+
# Must NOT raise — this is the whole point of the fix.
531+
assert _swallow_done_exception(task) is None
532+
533+
@pytest.mark.asyncio
534+
async def test_retrieves_exception_for_failed_task(self) -> None:
535+
async def _boom() -> None:
536+
raise RuntimeError("kaboom")
537+
538+
task = asyncio.ensure_future(_boom())
539+
with pytest.raises(RuntimeError, match="kaboom"):
540+
await task
541+
# Retrieves the exception so asyncio doesn't warn — does not re-raise.
542+
assert _swallow_done_exception(task) is None
543+
544+
@pytest.mark.asyncio
545+
async def test_returns_none_for_successful_task(self) -> None:
546+
async def _ok() -> None:
547+
return None
548+
549+
task = asyncio.ensure_future(_ok())
550+
await task
551+
assert _swallow_done_exception(task) is None
552+
553+
507554
# ---------------------------------------------------------------------------
508555
# build_mirror_batcher / session_store_flush
509556
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)