Summary
SubprocessCLITransport._handle_stderr at src/claude_agent_sdk/_internal/transport/subprocess_cli.py:518-535 wraps an async for loop in a single except Exception: pass outside the loop. When the user-provided options.stderr callback raises (even once), the exception is caught at the outer level, the loop terminates, and no further stderr lines are delivered for the rest of the session.
Reproducer
5/5 reproduction. Mock stderr stream with 2 lines and a callback that raises RuntimeError:
import asyncio
from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
from claude_agent_sdk.types import ClaudeAgentOptions
count = 0
def raising(line):
global count
count += 1
raise RuntimeError(f"oops: {line!r}")
async def main():
options = ClaudeAgentOptions(stderr=raising)
transport = SubprocessCLITransport(prompt="x", options=options)
async def mock_iter():
yield "line 1"
yield "line 2"
transport._stderr_stream = mock_iter()
transport._options = options
await transport._handle_stderr()
print(f"count = {count}") # prints "count = 1" — second line dropped
asyncio.run(main())
Result: count = 1. Second line never reaches the callback. Function returns normally — no log, no traceback, no indication that anything went wrong.
Impact
- Functional regression: a single buggy stderr line (or one transient state in the user's callback) permanently disables stderr piping for the rest of the session. Subsequent CLI errors that would otherwise reach the user's logger are lost.
- Debugging trap: the user has no way to know their callback errored. The
except Exception: pass is silent.
- The signature
stderr: Callable[[str], None] | None (types.py:1741) doesn't document any "must not raise" contract.
Suggested fix
Move the try/except inside the loop and log at debug level so processing continues:
async for line in self._stderr_stream:
line_str = line.rstrip()
if not line_str:
continue
if self._options.stderr:
try:
self._options.stderr(line_str)
except Exception:
logger.debug("stderr callback raised", exc_info=True)
The outer except anyio.ClosedResourceError should stay (legitimate end-of-stream).
Environment
- claude-agent-sdk-python @
9aafd84 (current main)
- Python 3.11.14
- macOS 25.3.0 / Darwin arm64
Summary
SubprocessCLITransport._handle_stderratsrc/claude_agent_sdk/_internal/transport/subprocess_cli.py:518-535wraps anasync forloop in a singleexcept Exception: passoutside the loop. When the user-providedoptions.stderrcallback raises (even once), the exception is caught at the outer level, the loop terminates, and no further stderr lines are delivered for the rest of the session.Reproducer
5/5 reproduction. Mock stderr stream with 2 lines and a callback that raises
RuntimeError:Result:
count = 1. Second line never reaches the callback. Function returns normally — no log, no traceback, no indication that anything went wrong.Impact
except Exception: passis silent.stderr: Callable[[str], None] | None(types.py:1741) doesn't document any "must not raise" contract.Suggested fix
Move the
try/exceptinside the loop and log at debug level so processing continues:The outer
except anyio.ClosedResourceErrorshould stay (legitimate end-of-stream).Environment
9aafd84(currentmain)