|
| 1 | +# 0038 — CLI: Handle SIGPIPE / EPIPE Gracefully Instead of Panicking |
| 2 | + |
| 3 | +**Priority:** P2 |
| 4 | +**Type:** bugfix |
| 5 | +**Module:** `src/main.rs`, all subcommands that write to stdout |
| 6 | +**Status:** Backlog |
| 7 | +**Complexity:** S |
| 8 | +**Created:** 2026-03-02 |
| 9 | + |
| 10 | +## Context |
| 11 | + |
| 12 | +Any `great` subcommand that writes to stdout panics (exit code 101) when the |
| 13 | +read end of a pipe closes before the write is complete. This is a POSIX SIGPIPE |
| 14 | +/ EPIPE condition. Rust's standard library does not install a SIGPIPE handler |
| 15 | +by default on Linux; instead, `println!` / `write!` propagate an `io::Error` |
| 16 | +which — when it reaches `main() -> Result<()>` — causes anyhow to print a |
| 17 | +backtrace and exit 101 (Rust's panic exit code). |
| 18 | + |
| 19 | +The failure is most reproducible via `great status --json` because it emits a |
| 20 | +large single `println!` call, but the same root cause affects every subcommand |
| 21 | +(`great doctor`, `great diff`, `great template list`, etc.). |
| 22 | + |
| 23 | +**Reproduction** (Ubuntu 24.04, Docker container, or any Linux shell): |
| 24 | + |
| 25 | +```bash |
| 26 | +# head reads 0 lines then closes the pipe — great exits 101 |
| 27 | +great status --json | head -0 |
| 28 | + |
| 29 | +# process-substitution variant — stderr shows broken pipe message |
| 30 | +great status --json > >(head -0) |
| 31 | +``` |
| 32 | + |
| 33 | +**Actual behavior:** |
| 34 | + |
| 35 | +- Exit code: 101 (Rust panic/anyhow error propagation) |
| 36 | +- stderr: `Error: write /dev/stdout: broken pipe` (or similar anyhow error) |
| 37 | + |
| 38 | +**Expected behavior:** |
| 39 | + |
| 40 | +- Exit code: 0 (or the process is terminated by SIGPIPE, which shells report |
| 41 | + as exit 141 — both are acceptable to downstream tooling) |
| 42 | +- No output to stderr |
| 43 | + |
| 44 | +**Root cause (code):** |
| 45 | + |
| 46 | +`src/cli/status.rs` line 415: |
| 47 | + |
| 48 | +```rust |
| 49 | +println!("{}", serde_json::to_string_pretty(&report)?); |
| 50 | +``` |
| 51 | + |
| 52 | +The `?` propagates the `BrokenPipe` `io::Error` to `main()`, which calls the |
| 53 | +anyhow error handler and exits 101. The same pattern exists wherever `println!` |
| 54 | +or `print!` is used across all subcommands. |
| 55 | + |
| 56 | +**Why this matters:** |
| 57 | + |
| 58 | +- CI pipelines doing `great status --json | jq '.has_issues'` panic if jq |
| 59 | + exits early (e.g. after finding the first match with `jq -e`). |
| 60 | +- `great status --json | head -1` — a common "is the tool healthy?" one-liner |
| 61 | + — reliably panics. |
| 62 | +- Any pipeline using `| grep -q`, `| head`, or `| wc -l` may trigger this. |
| 63 | +- The panic message on stderr is alarming and confusing; users file bug reports |
| 64 | + thinking the CLI is broken, not that the pipe closed. |
| 65 | +- `great status --json` is documented to always exit 0 in JSON mode (by |
| 66 | + design); exiting 101 on EPIPE is an undocumented and inconsistent exception. |
| 67 | + |
| 68 | +## Acceptance Criteria |
| 69 | + |
| 70 | +- [ ] `great status --json | head -0` exits 0 (or 141) and produces no output |
| 71 | + on stderr. Verified by: `great status --json | head -0; echo $?` returning |
| 72 | + 0 or 141, with stderr empty. |
| 73 | + |
| 74 | +- [ ] `great status --json | head -1` exits 0 (or 141) with no stderr output, |
| 75 | + confirming the fix works when some output is consumed before the pipe closes. |
| 76 | + |
| 77 | +- [ ] At least two additional subcommands that write to stdout (`great doctor`, |
| 78 | + `great diff`, or `great template list`) are verified to not panic on |
| 79 | + `| head -0` — confirming the fix is applied at the binary entry point rather |
| 80 | + than per-call-site. |
| 81 | + |
| 82 | +- [ ] Normal operation is unaffected: `great status --json` with stdout open |
| 83 | + exits 0 and produces valid JSON; `great status` (human mode) with issues |
| 84 | + still exits 1 as before. |
| 85 | + |
| 86 | +- [ ] `cargo test` passes with no regressions. |
| 87 | + |
| 88 | +## Suggested Fix Approaches |
| 89 | + |
| 90 | +Three options exist; exactly one should be chosen and documented in the commit: |
| 91 | + |
| 92 | +**Option A — `unix_sigpipe` attribute (Rust 1.73+, simplest):** |
| 93 | +Add `#[unix_sigpipe = "sig_dfl"]` to `fn main()` in `src/main.rs`. This |
| 94 | +restores the default POSIX SIGPIPE disposition so the kernel kills the process |
| 95 | +cleanly (exit 141) rather than delivering an `io::Error`. |
| 96 | + |
| 97 | +**Option B — Ignore SIGPIPE at startup, swallow BrokenPipe errors:** |
| 98 | +Call `unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) }` early in `main`, |
| 99 | +then wrap the top-level `Result` handler to treat `io::ErrorKind::BrokenPipe` |
| 100 | +as a clean exit 0. |
| 101 | + |
| 102 | +**Option C — Wrap stdout writes per-call-site:** |
| 103 | +Replace `println!` calls with explicit `writeln!(std::io::stdout(), ...)` and |
| 104 | +match on `io::ErrorKind::BrokenPipe` to return `Ok(())` instead of |
| 105 | +propagating. |
| 106 | + |
| 107 | +Option A is preferred: it is the least invasive, requires no unsafe code or |
| 108 | +extra dependencies, and matches the behavior users expect from a POSIX CLI tool. |
| 109 | + |
| 110 | +## Files That Need to Change |
| 111 | + |
| 112 | +- `src/main.rs` — primary fix location (Option A: one-line attribute) |
| 113 | +- Potentially `src/cli/status.rs`, `src/cli/doctor.rs`, `src/cli/diff.rs`, |
| 114 | + `src/cli/template.rs` if a per-call-site approach (Option C) is chosen |
| 115 | + |
| 116 | +## Dependencies |
| 117 | + |
| 118 | +None. This is a self-contained fix to the binary entry point (or individual |
| 119 | +write sites). |
| 120 | + |
| 121 | +## Out of Scope |
| 122 | + |
| 123 | +- Changing exit codes for non-EPIPE error paths (separate concern). |
| 124 | +- Handling SIGPIPE in the MCP bridge server (that process is long-lived and |
| 125 | + EPIPE there has different semantics — track separately if needed). |
| 126 | +- Windows behavior (Windows does not have SIGPIPE; the fix is no-op or |
| 127 | + conditional on `#[cfg(unix)]`). |
0 commit comments