Skip to content

Commit 6bbad5f

Browse files
seeincodesXian Zhengclaude
authored
fix(transport): isolate stderr callback failures, continue reading lines (#932)
## Summary Fixes #929. `SubprocessCLITransport._handle_stderr` wrapped the entire `async for` loop in a single `except Exception: pass`, so a raise from the user-provided `options.stderr` callback was caught at the outer level — the loop terminated and no further stderr lines were delivered for the rest of the session. Silent: no log, no traceback. The repro in #929 confirmed a callback that raises on the first line dropped all subsequent lines (`callback_raised_count = 1` for a 2-line stream). The contract on `stderr: Callable[[str], None]` (`types.py:1741`) doesn't document any "must not raise" constraint, so this is a bug, not user error. ## Changes - `src/claude_agent_sdk/_internal/transport/subprocess_cli.py`: per-line `try/except` around `self._options.stderr(line_str)` so a buggy callback fails for that one line but the loop continues. The outer `except Exception: pass` becomes `logger.debug(..., exc_info=True)` so stream-read failures are at least visible at debug level. The `except anyio.ClosedResourceError` for legitimate end-of-stream is preserved. - `tests/test_transport.py`: regression test `test_stderr_callback_raise_does_not_terminate_loop` — 3-line stream, callback raises on line 1, asserts all 3 lines delivered. ## Test plan - [x] `uv run pytest tests/test_transport.py` — 90 passed - [x] `uv run mypy src/` — clean - [x] `ruff check / ruff format` — clean - [x] Manual repro from issue body now shows `count = 3` (was `count = 1` before fix) Co-authored-by: Xian Zheng <xian.zheng@challenger.gauntletai.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fc7a6de commit 6bbad5f

2 files changed

Lines changed: 43 additions & 3 deletions

File tree

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,13 +526,21 @@ async def _handle_stderr(self) -> None:
526526
if not line_str:
527527
continue
528528

529-
# Call the stderr callback if provided
529+
# Call the stderr callback if provided. Isolate per-line so a
530+
# raise in the user's callback doesn't terminate the loop and
531+
# silently drop every subsequent line for the rest of the
532+
# session.
530533
if self._options.stderr:
531-
self._options.stderr(line_str)
534+
try:
535+
self._options.stderr(line_str)
536+
except Exception:
537+
logger.debug(
538+
"stderr callback raised; continuing", exc_info=True
539+
)
532540
except anyio.ClosedResourceError:
533541
pass # Stream closed, exit normally
534542
except Exception:
535-
pass # Ignore other errors during stderr reading
543+
logger.debug("stderr stream read failed", exc_info=True)
536544

537545
async def close(self) -> None:
538546
"""Close the transport and clean up resources."""

tests/test_transport.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import uuid
5+
from collections.abc import AsyncIterator
56
from contextlib import nullcontext
67
from unittest.mock import AsyncMock, MagicMock, patch
78

@@ -2071,6 +2072,37 @@ async def _test():
20712072

20722073
anyio.run(_test)
20732074

2075+
def test_stderr_callback_raise_does_not_terminate_loop(self) -> None:
2076+
"""Regression for issue #929: a raise from ``options.stderr`` must not
2077+
kill the read loop. Previously the outer ``except Exception: pass``
2078+
caught it, exited the ``async for``, and silently dropped every
2079+
subsequent stderr line for the rest of the session."""
2080+
2081+
async def _test() -> None:
2082+
received: list[str] = []
2083+
2084+
def stderr_cb(line: str) -> None:
2085+
received.append(line)
2086+
if len(received) == 1:
2087+
raise RuntimeError("simulated handler failure")
2088+
2089+
transport = SubprocessCLITransport(
2090+
prompt="x", options=ClaudeAgentOptions(stderr=stderr_cb)
2091+
)
2092+
2093+
async def mock_iter() -> AsyncIterator[str]:
2094+
yield "line 1"
2095+
yield "line 2"
2096+
yield "line 3"
2097+
2098+
transport._stderr_stream = mock_iter() # type: ignore[assignment]
2099+
await transport._handle_stderr()
2100+
2101+
# All three lines must be delivered despite the first raise.
2102+
assert received == ["line 1", "line 2", "line 3"]
2103+
2104+
anyio.run(_test)
2105+
20742106

20752107
class TestAtexitChildCleanup:
20762108
"""Tests for the atexit handler that terminates orphaned CLI subprocesses."""

0 commit comments

Comments
 (0)