Skip to content

fix(derive): ignore closed pipes instead of panicking in generated parse() - #1484

Merged
jdx merged 1 commit into
mainfrom
fix-derive-broken-pipe
Sep 23, 2026
Merged

jdx merged 1 commit into
mainfrom
fix-derive-broken-pipe

Conversation

@jdx

@jdx jdx commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

https://entire.io/gh/jdx/usage/trails/34

A CLI built with #[derive(usage_rs::Cli)] no longer crashes when the process reading its output goes away. Before this fix, the generated Cli::parse() printed with print!/eprint!, which panic on a failed write. So mycli --help | head -1, a pager quit early, or a completion that an as-you-type shell driver cancelled crashed the CLI. In a panic = "abort" build it aborted with SIGABRT and a core dump.

Before (a derive CLI, reader closed):

$ mycli --version | true
thread 'main' panicked at library/std/src/io/stdio.rs:
failed printing to stdout: Broken pipe (os error 32)

After: it exits with the status it would have had anyway: 0 for help, version, spec and completion answers, and 2 for a failure rendered to a closed stderr.

This affects every output path in the generated entry point:

  • help and --version (__usage_exit_on_error, __usage_exit_version)
  • the __usage_spec__ endpoint and the completion protocol
  • parse failures, and deprecation warnings after a successful parse

All of them now go through two hidden usage_argv helpers, __usage_print and __usage_eprint, which ignore the write error. Each caller either exits right away or has already finished a successful parse, so nobody is left to report the error to. Exit statuses don't change.

Found through jdx/hk#1441, where hk's cancelled completions aborted inside the generated parse(). hk is working around it with a panic hook in jdx/hk#1442. mise isn't affected because it doesn't call parse(); it was fixed separately in jdx/mise#13527.

Testing

New a_closed_reader_is_not_a_crash in usage-rs/tests/external.rs runs the runtime-identity fixture with its stdout attached to a pipe whose read end is dropped before the child starts, so the write always fails and nothing is left to timing. It covers --help, --version and __usage_spec__ on a closed stdout, and a parse failure on a closed stderr.

  • It fails on main (exit 101 on --help) and passes with this change.
  • cargo test --workspace and cargo clippy --workspace --all-features --all-targets pass.
  • mise run render and mise run gen-shadow produce no diff.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5-5; version: 2.1.270.

🤖 Generated with Claude Code


Note

Low Risk
Behavior change is limited to error handling on I/O write failure at process exit; exit statuses are unchanged and successful writes are unaffected.

Overview
Generated Cli::parse() and related entry points no longer panic on broken pipes when the reader closes early (--help | head, cancelled shell completions, etc.). Previously print!/eprint! turned a failed write into a crash (worse under panic = "abort").

The runtime adds hidden __usage_print and __usage_eprint helpers that write to stdout/stderr and ignore write errors, preserving the exit codes callers already chose (0 for help/version/spec/completion, 2 for failures). __usage_exit_on_error, __usage_exit_version, and the derive codegen for deprecation warnings, __usage_spec__, and completion intercepts all route through these helpers instead of print!/eprint!.

A new integration test runs a fixture with a deliberately closed pipe on stdout or stderr and asserts success/failure exit codes without a panic message.

Reviewed by Cursor Bugbot for commit c81e4f9. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes
    • Help, version, and specification output no longer causes a panic if the receiving process closes its output stream early.
    • Warning and error messages are also handled safely when the output stream is unavailable. Errors continue to return the appropriate exit status.

…rse()

`Cli::parse()` printed help, version, spec and completion answers with
`print!`, and failures and deprecation warnings with `eprint!`. Those
panic when the reader has gone away, so `cli --help | head -1` or a
completion the shell cancelled crashed the CLI, and aborted with a core
dump in `panic = "abort"` builds. Route them through `usage_argv` helpers
that ignore a failed write; exit statuses are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 4591eaf8-f867-48d4-8a8d-3136796bd897

📥 Commits

Reviewing files that changed from the base of the PR and between 9aaedef and c81e4f9.

📒 Files selected for processing (3)
  • argv/src/lib.rs
  • derive/src/codegen.rs
  • usage-rs/tests/external.rs

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Usage output and generated parser output now use helpers that ignore write and flush failures. An integration test checks selected commands with closed stdout or stderr pipes.

Changes

Safe output handling

Layer / File(s) Summary
Add safe output helpers and route usage output
argv/src/lib.rs
Two hidden helpers write formatted output to locked stdout or stderr and ignore write and flush failures. Usage error, help, and version output uses these helpers.
Route generated output and test closed pipes
derive/src/codegen.rs, usage-rs/tests/external.rs
Generated parser warnings, spec responses, and completion responses use the helpers. The integration test checks help, version, spec, and error output with closed pipes.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Suggested reviewers: lu-zero

Merge Risk: ⚪ Minimal · up to c81e4

The output changes are ready for normal checks before merging; no actionable risk remains identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: generated parse() now ignores closed-pipe write failures instead of panicking.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 10 functions across 3 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking regression in captured output for successful in-process parses that emit deprecation warnings.

Fix All in Claude CodeFindings

  1. P2 Direct writes bypass capture ▶

Summary

The PR makes generated CLI output resilient to closed stdout and stderr pipes by replacing panicking formatting macros with hidden helpers that discard write failures.

  • Routes help, version, errors, warnings, specification responses, and completion responses through the new helpers.
  • Preserves the caller-selected exit status after failed writes.
  • Adds deterministic subprocess coverage for closed stdout and stderr.

Reviews (1) · Last reviewed commit: "fix(derive): ignore closed pipes instead..."

Comment thread argv/src/lib.rs
@jdx
jdx merged commit 7e7b7ff into main Sep 23, 2026
13 checks passed
@jdx
jdx deleted the fix-derive-broken-pipe branch September 23, 2026 18:04
@github-actions

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▆▇▄▁▆█▄▄▁▁▇ 285,364,230 → 285,435,449 +0.02% 49.78 → 49.98ms +0.41%
startup ▆▆▆▆▆██▄▃▁▄ 1,046,214 → 1,055,246 +0.86% 1.37 → 1.40ms +2.56%

No instruction-count regression above 1%.

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

framework stripped binary, bytes
usage 1261984
bpaf 2494656
clap 3100904
framework instructions, cold parse vs usage
usage 8512 —
clap 6314907 741x
bpaf 21909296 2573x
                                              min       p01       p10    median
usage-rs: argv -> struct                      785       792       802       818  ns
clap: build tree + parse -> struct        1251701   1259136   1267854   1282145  ns
bpaf: build parser + parse -> struct      3400446   3400446   3439978   3472074  ns

usage: argv -> struct                             797 ns      0.80 µs
clap: build tree + parse -> struct            1253470 ns   1253.47 µs
clap: parse -> struct, tree reused              50983 ns     50.98 µs
clap: build tree only                          746333 ns    746.33 µs

c81e4f93d822 vs 9aaedefb1221 · measured on the runner, not pushed to the history.

jdx pushed a commit that referenced this pull request Sep 28, 2026
<!-- entire-trail-link-start -->
https://entire.io/gh/jdx/usage/trails/33
<!-- entire-trail-link-end -->

### 🚀 Features

- **(cli)** publish a usage agent skill with packslip by
[@jdx](https://github.com/jdx) in
[#1509](#1509)
- **(complete)** complete a wrapped command's arguments with its own
shell completion via delegate= by [@jdx](https://github.com/jdx) in
[#1498](#1498)
- **(lib)** add getters that read FlagMeta, CommandMeta and ArgMeta
fields wherever they live by [@jdx](https://github.com/jdx) in
[#1491](#1491)
- **(lib)** export the chosen subcommand to scripts as usage_cmd by
[@jdx](https://github.com/jdx) in
[#1505](#1505)
- **(spec)** let a complete node sit inside the arg it completes by
[@jdx](https://github.com/jdx) in
[#1496](#1496)
- **(spec)** list an arg's possible values from a command with `choices
run=` by [@jdx](https://github.com/jdx) in
[#1497](#1497)

### 🐛 Bug Fixes

- **(bash)** complete `--flag=` values when the cursor sits after `=` by
[@jdx](https://github.com/jdx) in
[#1499](#1499)
- **(cli)** run scripts passed to usage bash through a pipe or process
substitution by [@jdx](https://github.com/jdx) in
[#1494](#1494)
- **(cli)** keep usage explain from running a spec's choices run=
commands by [@jdx](https://github.com/jdx) in
[#1503](#1503)
- **(complete)** read --line=LINE in completion requests from typed
completers by [@jdx](https://github.com/jdx) in
[#1487](#1487)
- **(complete)** show completion parse errors without garbling the
prompt by [@jdx](https://github.com/jdx) in
[#1495](#1495)
- **(derive)** ignore closed pipes instead of panicking in generated
parse() by [@jdx](https://github.com/jdx) in
[#1484](#1484)
- **(derive)** keep generated async dispatch from reserving stack for
every command by [@jdx](https://github.com/jdx) in
[#1488](#1488)
- **(fish)** complete words the user has started quoting by
[@jdx](https://github.com/jdx) in
[#1500](#1500)
- **(lib)** resolve inherited usage aliases after multiline workspace
entries by [@jdx](https://github.com/jdx) in
[#1507](#1507)
- **(lib)** make published usage-rs tests self-contained by
[@jdx](https://github.com/jdx) in
[#1510](#1510)
- **(zsh)** stop the completion-init handler from breaking file
completion for other commands by [@jdx](https://github.com/jdx) in
[#1493](#1493)

### 📚 Documentation

- **(cli)** describe fig output as the legacy Fig format by
[@jdx](https://github.com/jdx) in
[2a1bef6](2a1bef6)
- clarify CLI frameworks and generators on the homepage by
[@jdx](https://github.com/jdx) in
[#1482](#1482)

### 🛡️ Security

- remove Entire trail runners by [@jdx](https://github.com/jdx) in
[#1485](#1485)

### 🔍 Other Changes

- float jdx tools and aube on latest without a release-age delay by
[@jdx](https://github.com/jdx) in
[#1486](#1486)
- run cargo-semver-checks on every published crate by
[@jdx](https://github.com/jdx) in
[#1490](#1490)
- fail pull requests that grow the usage CLI or a derived CLI by more
than 1% by [@jdx](https://github.com/jdx) in
[#1492](#1492)
- make the binary-size check measure the pull request's own code by
[@jdx](https://github.com/jdx) in
[#1501](#1501)

### 📦️ Dependency Updates

- bump jdx/renovate-config workflows to c736149 by
[@jdx](https://github.com/jdx) in
[06f2f1b](06f2f1b)
- bump jdx/renovate-config workflows to aa49efc by
[@jdx](https://github.com/jdx) in
[9aaedef](9aaedef)
- bump jdx/renovate-config workflows to 5b46432 by
[@jdx](https://github.com/jdx) in
[36f8681](36f8681)
- pin jdx/renovate-config workflows to v1.0.0 by
[@jdx](https://github.com/jdx) in
[a31ae2f](a31ae2f)
- update communique to 1.4.2 in mise.lock by
[@jdx](https://github.com/jdx) in
[805396f](805396f)
- upgrade locked mise tools by [@jdx](https://github.com/jdx) in
[d8b761d](d8b761d)
- update jdx/packslip action to v1.4.0 by [@jdx](https://github.com/jdx)
in [#1508](#1508)
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.

1 participant