How a command-line program ends
README.md

cli-exit #

Exit statuses and closed-pipe handling for cmdliner programs.

Command-line tools are routinely piped into head, grep -q or an awk script that stops once it has found its line, and each of those closes the pipe while the tool still has output to write. That is the normal end of a pipeline, yet exit (Cmdliner.Cmd.eval cmd) answers it in one of two ways depending on what else runs in the process. A write to a pipe whose reader has gone raises SIGPIPE, signal 13, in the writer. When something (Eio, for instance) has set SIGPIPE to be ignored, the write fails instead, and the program prints internal error, uncaught exception: Sys_error("Broken pipe") and exits with 125, the status that says the tool itself is broken. When nothing has, the signal kills the process, and the shell reports status 141: a process killed by a signal has no exit code of its own, so the shell reports 128 plus the signal number. A tool that reports an error on every | head teaches its users to ignore the word "error".

cli-exit decides these endings once, for every program that uses it. Cli_exit.run is the whole main of a cmdliner program: it ignores SIGPIPE before cmdliner writes anything, ends the process quietly with status 0 when the reader has gone, reports an unexpected exception the way cmdliner reports one, and exits with the status the evaluation returns. It also writes help to standard output instead of a pager, so that tool --help | grep works and a script never waits on a pager nobody can quit.

Install #

opam install cli-exit

Usage #

A program hands its command to Cli_exit.run, which never returns:

let cmd =
  let doc = "Print a greeting" in
  Cmdliner.Cmd.v
    (Cmdliner.Cmd.info "greet" ~doc)
    Cmdliner.Term.(const (fun () -> print_endline "hello") $ const ())

let main () = Cli_exit.run cmd

Some exceptions are refusals the user can act on, such as a mistyped argument converted before the term dispatches, rather than defects in the program. The ~refusal argument maps such an exception to the sentence to print:

let main cmd =
  Cli_exit.run ~refusal:(function Failure m -> Some m | _ -> None) cmd

A refusal is printed on stderr under the program's name, with no backtrace and no "internal error", and the process exits with Cli_exit.refused (1). Without ~refusal, the user is told that their typo is a bug in the tool.

Cli_exit.info builds a Cmdliner.Cmd.info whose manual states these behaviours: its EXIT STATUS section comes from Cli_exit.exits, and its --help item says that help is never paged. A command with an exit status of its own prepends an entry to Cli_exit.exits.

Writes to Eio.Stdenv.stdout bypass the standard channels, and Eio reports a closed pipe on them as a reset connection that does not say which flow it was on. A program that also talks to sockets cannot tell the two apart, so the call site says which writes go to the program's own output by wrapping them in Cli_exit.writing_output.

Python documents the same problem in the notes to signal.SIGPIPE and recommends restoring the default disposition. That is not an option here: Eio ignores SIGPIPE so that a socket write returns EPIPE instead of killing the process. Rust's standard library also leaves SIGPIPE ignored, and crates such as calm_io sit at the program's main, where this library sits, and turn the resulting error into a quiet exit.

No other OCaml library covers it. Cmdliner provides the hook this library uses, the ~catch:false argument to Cmdliner.Cmd.eval, and documents that a program passing it takes over the error report and the exit status.

License #

ISC. See LICENSE.md.