Skip to content

fix(cli): flush stdio before exiting so piped output survives - #1411

Open
willwashburn wants to merge 2 commits into
mainfrom
claude/github-issue-1371-kny3sd
Open

fix(cli): flush stdio before exiting so piped output survives#1411
willwashburn wants to merge 2 commits into
mainfrom
claude/github-issue-1371-kny3sd

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #1371agent-relay cloud session --json emitting nothing when stdout is not a TTY.

The command itself never gated on a TTY; the output was written and then thrown away. Node's process.stdout / process.stderr writes are synchronous for files and POSIX TTYs but asynchronous for pipes on macOS, so a process.exit() in the same tick as the write discards whatever is still buffered. Piping or $(...)-capturing a run that exits through one of those paths yields empty stdout and empty stderr — including the error text that would have explained the failure — while the identical run in a terminal prints fine. That asymmetry is what made the bug read as TTY-conditional.

Changes:

  • New packages/cli/src/cli/lib/flush-stdio.ts: flushStream / flushStdio / exitAfterFlush. A zero-length write is queued behind the pending chunks, so its completion callback is the signal that the earlier writes reached the OS. The wait is bounded (2s default, unref'd timer) so a stalled reader can never wedge the exit, and a throwing/destroyed stream resolves rather than hangs.
  • Route the three hard-exit paths through exitAfterFlush: the top-level failure handler in index.ts, the CliExit path in runCli (bootstrap.ts), and runSignalHandler (lib/exit.ts).
  • Changelog entry under [Unreleased - Patch].

No behavior change to the success path of cloud session --json beyond the guarantee — it never called process.exit, so its output already survived; the silent-failure case is what this recovers.

Test Plan

  • Tests added/updated
    • packages/cli/src/cli/lib/flush-stdio.test.ts — a fake stream that completes its write callback out-of-band proves exitAfterFlush does not exit until the drain lands, that the timeout releases a stalled stream, and that destroyed/undefined/throwing streams resolve. Platform-independent, so it holds on Linux CI.
    • packages/cli/src/cli/commands/cloud.test.tscloud session --json with process.stdout.isTTY = false and no dependency overrides, asserting the production console.log path emits the JSON.
  • Manual testing completed
    • Built the CLI and ran cloud session --json piped, redirected, $(...)-captured, and with stdin on /dev/null — JSON on stdout each time.
    • Failure path (stored token needing a refresh against an unreachable host): exit code 1 with the error on stderr, preserved through the new flush.
    • npx vitest run packages/cli → 888 passed, 11 skipped; tsc --noEmit clean; npm run lint unchanged (0 errors); prettier --check clean.
    • Note: macOS is where async pipe writes actually bite; verification here ran on Linux, where pipes are synchronous, so the manual runs confirm no regression rather than reproducing the original loss.

🤖 Generated with Claude Code


Generated by Claude Code

Review in cubic

`agent-relay cloud session --json` (and any other command) could emit
nothing at all — empty stdout *and* empty stderr — when stdout was a pipe
rather than a TTY. Node's stdio writes are synchronous for files and POSIX
TTYs but asynchronous for pipes on macOS, so a `process.exit()` in the same
tick as the write discards whatever is still buffered. Interactively the
same command printed fine, which made the failure look TTY-conditional.

Add `exitAfterFlush()`, which drains stdout and stderr (bounded by a
timeout, so a stalled reader can't wedge the exit) before calling
`process.exit`, and route the three hard-exit paths through it: the
top-level failure handler, the `CliExit` path in `runCli`, and
`runSignalHandler`.

Fixes #1371

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129AoahEiYiRdbPGQjNpjdf
@willwashburn
willwashburn requested a review from khaliqgant as a code owner July 31, 2026 03:38
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The patch adds bounded stdio flushing before CLI hard exits. It updates error and signal exit paths and adds regression coverage for non-TTY cloud session JSON output.

Changes

CLI output and exit handling

Layer / File(s) Summary
Stdio flush utility
packages/cli/src/cli/lib/flush-stdio.ts, packages/cli/src/cli/lib/flush-stdio.test.ts
Adds concurrent stdout and stderr flushing with timeout, closed-stream, and write-error handling. Tests cover the utility and delayed exit behavior.
Hard-exit integration
packages/cli/src/cli/bootstrap.ts, packages/cli/src/cli/index.ts, packages/cli/src/cli/lib/exit.ts, CHANGELOG.md
CLI exit paths flush standard streams before termination and preserve requested exit codes. The changelog records the behavior.
Non-TTY session output
packages/cli/src/cli/commands/cloud.test.ts
Tests redacted JSON output through the production logger when stdout is not a TTY.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: khaliqgant

Poem

Poem

A rabbit flushes streams with care,
So piped words reach the waiting air.
Exit codes stay precise and bright,
JSON travels without a TTY’s light.
The CLI closes clean and clear.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary CLI change: flushing standard I/O before exit to preserve piped output.
Description check ✅ Passed The description includes the required summary and test plan, with detailed implementation, validation results, and no applicable screenshots.
Linked Issues check ✅ Passed The changes address issue #1371 by preserving non-TTY session output and error output through bounded flushing on hard-exit paths.
Out of Scope Changes check ✅ Passed The implementation, regression tests, changelog entry, and validation steps are related to the linked issue and stated CLI output objective.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/github-issue-1371-kny3sd

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/cli/src/cli/commands/cloud.test.ts (1)

426-429: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Add an end-to-end stdout assertion for the output-loss regression.

This test replaces console.log and checks only its arguments. It does not verify that bytes reach process.stdout before CLI termination. Add a child-process or real-stream test with non-TTY stdout that asserts the redacted JSON is received after the command exits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/commands/cloud.test.ts` around lines 426 - 429, Add an
end-to-end test for the CLI output path around the existing console.log spy,
using a child process or real non-TTY process.stdout instead of mocking
console.log. Execute the command, wait for it to exit, and assert that stdout
contains the expected redacted JSON, verifying bytes are emitted before
termination.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 12: Update the changelog section heading from “## [Unreleased - Patch]”
to the standard “## [Unreleased]”, keeping the existing entry under the “###
Fixed” subsection.

---

Nitpick comments:
In `@packages/cli/src/cli/commands/cloud.test.ts`:
- Around line 426-429: Add an end-to-end test for the CLI output path around the
existing console.log spy, using a child process or real non-TTY process.stdout
instead of mocking console.log. Execute the command, wait for it to exit, and
assert that stdout contains the expected redacted JSON, verifying bytes are
emitted before termination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ab25c0ec-605f-44d9-95b9-f46908bb0ca7

📥 Commits

Reviewing files that changed from the base of the PR and between 693f23f and 2491e0b.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • packages/cli/src/cli/bootstrap.ts
  • packages/cli/src/cli/commands/cloud.test.ts
  • packages/cli/src/cli/index.ts
  • packages/cli/src/cli/lib/exit.ts
  • packages/cli/src/cli/lib/flush-stdio.test.ts
  • packages/cli/src/cli/lib/flush-stdio.ts

Comment thread CHANGELOG.md

### Fixed

- CLI output no longer disappears when stdout or stderr is a pipe instead of a terminal. Node's stdio writes are asynchronous for pipes on macOS, so exiting in the same tick as the write discarded whatever was still buffered — `agent-relay cloud session --json | parser` and `$(agent-relay …)` could come back with empty stdout _and_ empty stderr, hiding the payload and the error that explained the failure. Every hard-exit path now drains stdio first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the standard unreleased heading.

Line 12 adds an unreleased entry, but the section heading is ## [Unreleased - Patch]. Change it to ## [Unreleased]. Keep this entry under ### Fixed.

As per coding guidelines, maintain the correctly leveled [Unreleased] heading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 12, Update the changelog section heading from “##
[Unreleased - Patch]” to the standard “## [Unreleased]”, keeping the existing
entry under the “### Fixed” subsection.

Source: Coding guidelines

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2491e0b784

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

timer.unref?.();

try {
stream.write('', () => settle());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch stream errors while flushing stdio

When a downstream reader closes a pipe while output is still pending (for example, a verbose command piped to head), the pending stdout write emits an asynchronous EPIPE error event. The callback and surrounding try/catch do not consume that event, and awaiting the flush gives Node time to treat it as unhandled, print a stack trace, and exit with code 1 instead of the command's requested code. This reproduces on Node 24 with a large process.stdout.write followed by this zero-length flush piped to head; install a temporary stream error handler during the flush or otherwise handle EPIPE explicitly.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

14-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Place this entry under [Unreleased].

The entry is currently below ## [11.3.1] - 2026-07-31, so it is recorded as part of the July 31, 2026 release. Move the existing bullet into a ### Fixed subsection under ## [Unreleased].

As per coding guidelines, curate CHANGELOG.md using Keep a Changelog and SemVer with a correctly leveled [Unreleased] section.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 14, Move the existing CLI stdio-draining bullet from
the 11.3.1 release section into a “### Fixed” subsection under “## [Unreleased]”
in CHANGELOG.md, preserving its text and Keep a Changelog heading hierarchy.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@CHANGELOG.md`:
- Line 14: Move the existing CLI stdio-draining bullet from the 11.3.1 release
section into a “### Fixed” subsection under “## [Unreleased]” in CHANGELOG.md,
preserving its text and Keep a Changelog heading hierarchy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: da52c69e-81da-4113-895e-5fdb322007c5

📥 Commits

Reviewing files that changed from the base of the PR and between 2491e0b and c0894eb.

📒 Files selected for processing (1)
  • CHANGELOG.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cloud session --json emits nothing when stdout is not a TTY

2 participants