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.
Related work #
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.