Skip to content

Commit 592ae3a

Browse files
Xian Zhengclaude
andcommitted
fix(transport): isolate stderr callback failures, continue reading
`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. The failure was silent: no log, no traceback. A reproducer at the regression test confirms a callback that raises on the first line previously dropped lines 2 and 3; with the fix all three lines are delivered. Move the ``try/except`` inside the loop and log at debug level so a buggy callback fails per-line but doesn't disable stderr piping. Also log (instead of silently swallow) at the outer level so a stream-read failure is at least visible at debug level. Closes #929 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9aafd84 commit 592ae3a

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)