Skip to content

Commit 9d2c650

Browse files
seeincodesXian Zhengclaude
authored
fix(transcript_mirror): handle CancelledError in eager-flush done callback (#931)
## Summary Fixes #930. 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 `Captured log teardown` block. ## Changes - `src/claude_agent_sdk/_internal/transcript_mirror_batcher.py`: replace `lambda t: t.exception()` with a module-level `_swallow_done_exception` helper that no-ops on cancelled tasks and otherwise retrieves the exception so asyncio doesn't warn. - `tests/test_transcript_mirror.py`: add `TestSwallowDoneException` with three cases — cancelled task (must not raise), failed task (retrieves exception, doesn't re-raise), successful task (no-op). ## Test plan - [x] `uv run pytest tests/test_transcript_mirror.py::TestSwallowDoneException` — 3 passed - [x] `uv run mypy src/` — clean - [x] `ruff check / ruff format` — clean - [x] Pre-existing #928 failures still present (unaffected by this change — different bug) ## Notes The fix is intentionally narrow — same call site, same intent (silence "Task exception was never retrieved" warnings), just cancellation-safe. No behavior change on the happy path. Co-authored-by: Xian Zheng <xian.zheng@challenger.gauntletai.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 687e22f commit 9d2c650

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
@@ -33,6 +33,7 @@
3333
MAX_PENDING_BYTES,
3434
MAX_PENDING_ENTRIES,
3535
TranscriptMirrorBatcher,
36+
_swallow_done_exception,
3637
)
3738
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
3839

@@ -523,6 +524,52 @@ async def append(self, key, entries):
523524
assert order == [1, 2, 3]
524525

525526

527+
# ---------------------------------------------------------------------------
528+
# _swallow_done_exception
529+
# ---------------------------------------------------------------------------
530+
531+
532+
class TestSwallowDoneException:
533+
"""Regression for issue #930: the eager-flush task's done-callback used
534+
``lambda t: t.exception()``, which raises ``CancelledError`` for cancelled
535+
tasks (Python 3.8+) and surfaces as a noisy "Exception in callback" log
536+
every time the SDK shuts down with pending eager flushes.
537+
"""
538+
539+
@pytest.mark.asyncio
540+
async def test_returns_none_for_cancelled_task(self) -> None:
541+
async def _hang() -> None:
542+
await asyncio.sleep(3600)
543+
544+
task = asyncio.ensure_future(_hang())
545+
task.cancel()
546+
with pytest.raises(asyncio.CancelledError):
547+
await task
548+
assert task.cancelled()
549+
# Must NOT raise — this is the whole point of the fix.
550+
assert _swallow_done_exception(task) is None
551+
552+
@pytest.mark.asyncio
553+
async def test_retrieves_exception_for_failed_task(self) -> None:
554+
async def _boom() -> None:
555+
raise RuntimeError("kaboom")
556+
557+
task = asyncio.ensure_future(_boom())
558+
with pytest.raises(RuntimeError, match="kaboom"):
559+
await task
560+
# Retrieves the exception so asyncio doesn't warn — does not re-raise.
561+
assert _swallow_done_exception(task) is None
562+
563+
@pytest.mark.asyncio
564+
async def test_returns_none_for_successful_task(self) -> None:
565+
async def _ok() -> None:
566+
return None
567+
568+
task = asyncio.ensure_future(_ok())
569+
await task
570+
assert _swallow_done_exception(task) is None
571+
572+
526573
# ---------------------------------------------------------------------------
527574
# build_mirror_batcher / session_store_flush
528575
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)