Skip to content

fix: restore default SIGPIPE disposition so piped output does not abort - #1128

Open
aidenhiggs wants to merge 1 commit into
railwayapp:masterfrom
aidenhiggs:fix/sigpipe-abort-on-broken-pipe
Open

fix: restore default SIGPIPE disposition so piped output does not abort#1128
aidenhiggs wants to merge 1 commit into
railwayapp:masterfrom
aidenhiggs:fix/sigpipe-abort-on-broken-pipe

Conversation

@aidenhiggs

Copy link
Copy Markdown

Problem

The CLI dies with SIGABRT and leaves a core dump behind whenever its stdout pipe
is closed before it finishes writing — ordinary shell usage like railway logs | head -n 5
or piping into a pager the user then quits.

thread 'main' panicked at library/std/src/io/stdio.rs:1166:9:
failed printing to stdout: Broken pipe (os error 32)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

On Linux distributions that collect core dumps (anything running systemd-coredump,
which is the default on Arch, Fedora, and others) each occurrence writes a ~220 KB
core to /var/lib/systemd/coredump/. Because a core is a verbatim copy of process
memory, those files can contain the user's Railway API token. On the machine where
I hit this, six cores had accumulated before I noticed.

Reproduction

Close the read end of the pipe before the CLI writes:

import subprocess, os, signal
r, w = os.pipe()
p = subprocess.Popen(["railway", "--help"], stdout=w, stderr=subprocess.PIPE)
os.close(w); os.close(r)
_, err = p.communicate()
print(p.returncode, signal.Signals(-p.returncode).name)  # -6 SIGABRT

Note that railway --help | head -1 does not reproduce it — the help text fits
in the 64 KB pipe buffer, so the write never fails. The reader has to go away
before the write lands.

Cause

Two things combine:

  1. Rust's standard library sets SIGPIPE to SIG_IGN before main runs, so a
    write to a closed pipe returns EPIPE instead of terminating the process.
    Nothing in this crate restores the default disposition — grep -rn "SIGPIPE" src/ returns no hits.
  2. print!/println! panic on write errors, and Cargo.toml sets
    panic = "abort" for the release profile, so that panic becomes a SIGABRT
    with a core dump rather than a quiet exit.

Fix

Restore SIG_DFL for SIGPIPE at the top of main, so the process terminates
silently on a broken pipe — the conventional behaviour for a Unix CLI, and what
users' shells already expect from | head.

This uses nix, which is already a dependency with the signal feature enabled,
so it adds no new crates. The change is #[cfg(unix)]-gated and is a no-op on
Windows, which has no SIGPIPE.

Verification

  • cargo check passes; no new warnings

  • cargo fmt --all -- --check passes

  • Built --release and ran the reproduction above against both binaries under
    identical conditions:

    binary result stderr
    stock 5.44.0 killed by SIGABRT (6), core dumped panic message
    patched killed by SIGPIPE (13), no core empty
  • railway --help | head -3 now exits 141 (128+13), matching the conventional
    behaviour of yes | head. Normal non-piped invocations are unaffected:
    railway --version and railway status behave exactly as before.

Tested on Linux x86_64 with rustc 1.98.0, against master at 5.44.0.

Note for maintainers

CI's label check requires a release label — release/patch seems right for this.

Rust's standard library sets SIGPIPE to SIG_IGN before main runs, so writing
to a closed pipe returns EPIPE rather than terminating the process. The
print!/println! macros panic on write errors, and because the release profile
sets panic = "abort", that panic becomes a SIGABRT with a core dump.

The user-visible effect is that ordinary shell usage such as
`railway logs | head -n 5` crashes the CLI once the reader exits. On systems
running systemd-coredump this writes a ~220 KB core dump per occurrence, and
because a core is a verbatim copy of process memory those files can contain
the user's Railway API token.

Restore SIG_DFL for SIGPIPE at the top of main so the process terminates
silently on a broken pipe, which is the conventional behaviour for a Unix
command line tool. This uses nix, already a dependency with the signal
feature enabled, and is cfg(unix)-gated so it is a no-op on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aidenhiggs

Copy link
Copy Markdown
Author

Flagging two things this PR needs from a maintainer, since neither is available
to me with read-only access:

  1. Workflow approval — as a first-time contributor, the Check, Lints,
    and Tests jobs are held pending approval, so label-check is currently the
    only job that has run.
  2. A release labellabel-check is red purely because no release/*
    label is set. release/patch seems right for a behaviour fix with no API
    change, but that's your call.

In the meantime I ran the CI steps locally against master at 5.44.0
(rustc 1.98.0, Linux x86_64):

  • cargo fmt --all -- --check — passes
  • cargo clippy --all-targets --all-features — passes
  • cargo test — passes (1215 passed, 0 failed)

Happy to adjust the approach if you'd rather handle BrokenPipe at the output
call sites than reset the signal disposition process-wide. Resetting SIG_DFL
is the smaller change and matches what most Rust CLIs do, but it does mean the
process dies on SIGPIPE rather than unwinding, so if there's cleanup you want
to run on that path the call-site approach would be the safer fit.

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