-
Notifications
You must be signed in to change notification settings - Fork 873
fix: capture stderr in ProcessError for better debugging (#641) #658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,6 +52,7 @@ def __init__( | |
| self._stdin_stream: TextSendStream | None = None | ||
| self._stderr_stream: TextReceiveStream | None = None | ||
| self._stderr_task_group: anyio.abc.TaskGroup | None = None | ||
| self._stderr_buffer: list[str] = [] | ||
| self._ready = False | ||
| self._exit_error: Exception | None = None # Track process exit errors | ||
| self._max_buffer_size = ( | ||
|
|
@@ -366,14 +367,9 @@ async def connect(self) -> None: | |
| if self._cwd: | ||
| process_env["PWD"] = self._cwd | ||
|
|
||
| # Pipe stderr if we have a callback OR debug mode is enabled | ||
| should_pipe_stderr = ( | ||
| self._options.stderr is not None | ||
| or "debug-to-stderr" in self._options.extra_args | ||
| ) | ||
|
|
||
| # For backward compat: use debug_stderr file object if no callback and debug is on | ||
| stderr_dest = PIPE if should_pipe_stderr else None | ||
| # Always pipe stderr so we can capture it for error reporting. | ||
| # User callbacks and debug mode are still honored in _handle_stderr(). | ||
| stderr_dest = PIPE | ||
|
|
||
| self._process = await anyio.open_process( | ||
| cmd, | ||
|
|
@@ -388,8 +384,8 @@ async def connect(self) -> None: | |
| if self._process.stdout: | ||
| self._stdout_stream = TextReceiveStream(self._process.stdout) | ||
|
|
||
| # Setup stderr stream if piped | ||
| if should_pipe_stderr and self._process.stderr: | ||
| # Setup stderr stream (always piped for error capture) | ||
| if self._process.stderr: | ||
| self._stderr_stream = TextReceiveStream(self._process.stderr) | ||
| # Start async task to read stderr | ||
| self._stderr_task_group = anyio.create_task_group() | ||
|
|
@@ -419,7 +415,7 @@ async def connect(self) -> None: | |
| raise error from e | ||
|
|
||
| async def _handle_stderr(self) -> None: | ||
| """Handle stderr stream - read and invoke callbacks.""" | ||
| """Handle stderr stream - read, buffer, and invoke callbacks.""" | ||
| if not self._stderr_stream: | ||
| return | ||
|
|
||
|
|
@@ -429,6 +425,9 @@ async def _handle_stderr(self) -> None: | |
| if not line_str: | ||
| continue | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Nit: Extended reasoning...What the bug isThe new Why the surface area expandedBefore this PR, stderr was only piped when a user-provided callback existed or debug mode was enabled ( Step-by-step proof
Practical impactThe practical risk is low. The subprocess is the Claude CLI, which produces limited stderr output proportional to conversation activity. Each transport instance is scoped to a single Recommended fixApply the same |
||
| # Always capture stderr for error reporting | ||
| self._stderr_buffer.append(line_str) | ||
|
|
||
| # Call the stderr callback if provided | ||
| if self._options.stderr: | ||
| self._options.stderr(line_str) | ||
|
|
@@ -485,6 +484,7 @@ async def close(self) -> None: | |
| self._stdout_stream = None | ||
| self._stdin_stream = None | ||
| self._stderr_stream = None | ||
| self._stderr_buffer = [] | ||
| self._exit_error = None | ||
|
|
||
| async def write(self, data: str) -> None: | ||
|
|
@@ -586,10 +586,11 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: | |
|
|
||
| # Use exit code for error detection | ||
| if returncode is not None and returncode != 0: | ||
| captured_stderr = "\n".join(self._stderr_buffer) if self._stderr_buffer else None | ||
| self._exit_error = ProcessError( | ||
| f"Command failed with exit code {returncode}", | ||
| exit_code=returncode, | ||
| stderr="Check stderr output for details", | ||
| stderr=captured_stderr, | ||
|
Comment on lines
586
to
+593
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Race condition: after Extended reasoning...Bug Analysis
Code PathWhen the subprocess exits, the write end of the stderr pipe is closed. However, there may still be unread data in the kernel pipe buffer. The Step-by-step proof
ImpactIn practice, the window for this race is small: Suggested FixAfter if self._stderr_task_group:
self._stderr_task_group.cancel_scope.cancel()
await self._stderr_task_group.__aexit__(None, None, None)
self._stderr_task_group = NoneThis ensures all buffered pipe data has been consumed before constructing the |
||
| ) | ||
| raise self._exit_error | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Behavioral regression: stderr output during successful CLI operations is now silently swallowed. Previously, when no stderr callback or debug mode was configured,
stderr_destwasNoneso the subprocess inherited the parent stderr FD and CLI warnings/deprecation notices appeared in the terminal. Now stderr is always piped but_handle_stderrhas no fallback — lines are buffered but never displayed, and the buffer is cleared inclose(). Consider adding anelse: sys.stderr.write(line_str + "\n")fallback in_handle_stderrwhen no callback/debug is configured.Extended reasoning...
What the bug is
Before this PR,
stderr_destwas conditionally set: it wasPIPEonly when a stderr callback was provided or debug mode was enabled, andNoneotherwise. WhenNone, the subprocess inherited the parent process's stderr file descriptor, meaning any CLI warnings, deprecation notices, or diagnostic messages written to stderr would appear directly in the user's terminal.After this PR,
stderr_destis unconditionally set toPIPE(line 372), and all stderr output flows through_handle_stderr(). However,_handle_stderronly has two branches for output: (1) invoke the user's stderr callback, or (2) write todebug_stderrwhen debug mode is active. There is no fallbackelsebranch for the case where neither is configured.Code path that triggers the issue
Consider a user who creates a
ClaudeAgentwith default options — nostderrcallback, nodebug-to-stderrinextra_args. The subprocess starts withstderr=PIPE. The_handle_stderrcoroutine reads each line from stderr, appends it toself._stderr_buffer(line 429), then checks:if self._options.stderr:— False, no callback setelif "debug-to-stderr" in self._options.extra_args and self._options.debug_stderr:— False, no debug modeNo output is produced. The line sits in
_stderr_bufferand is never displayed.Step-by-step proof with a concrete example
ClaudeAgentwith default options (no stderr callback, no debug mode)stderr=PIPE"Warning: --verbose flag is deprecated, use --debug instead"_handle_stderrreads the line, appends it to_stderr_buffer_read_messages_implfinishes without raisingProcessError(since exit code is 0)close()is called, which resetsself._stderr_buffer = [](line 487)Impact
Users who previously relied on seeing CLI stderr output in their terminal during normal (successful) operations will no longer see it. This includes warnings, deprecation notices, and any other diagnostic output the CLI emits to stderr. The stderr buffer is only used when constructing a
ProcessErroron non-zero exit, so all stderr from successful runs is silently discarded.How to fix
Add a fallback
elsebranch in_handle_stderrthat writes tosys.stderrwhen no callback or debug mode is configured:This preserves the new stderr-capture behavior for error reporting while maintaining backward compatibility for terminal visibility.