Skip to content

Bug: stderr callback raise silently kills _handle_stderr loop, dropping all subsequent stderr lines #929

@seeincodes

Description

@seeincodes

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions