Skip to main content

prick_exec/
signal.rs

1//! Exit codes, signals and job control.
2//!
3//! # The `SIGPIPE` regression
4//!
5//! The Rust runtime sets `SIGPIPE` to `SIG_IGN` before `main` runs, because a
6//! Rust program would rather see `EPIPE` from a write than die. That setting is
7//! inherited across `exec`, so a child launched by `prk run` starts with
8//! `SIGPIPE` ignored -- which is not what it was written to expect.
9//!
10//! Concretely, `prk run -- yes | head -1` **hangs forever**: `head` exits, the
11//! pipe closes, and `yes` never receives the signal that would stop it. Every
12//! program in a pipeline that relies on `SIGPIPE` to know when to stop is
13//! affected, which is most of them.
14//!
15//! The fix is [`restore_default_dispositions`], called from `pre_exec`
16//! immediately before the image is replaced. Its regression test is
17//! `tests/unix_exec.rs::yes_piped_into_head_terminates_rather_than_hanging`.
18//!
19//! # The signal mask
20//!
21//! Blocked signals are also inherited across `exec`, and a blocked signal is
22//! not something the child can discover or undo before it matters. `prk` does
23//! not block anything itself, but it may have been started by something that
24//! did, so the mask is cleared rather than assumed empty.
25
26/// The offset a shell adds to a signal number to form an exit status.
27pub const SIGNAL_EXIT_BASE: i32 = 128;
28
29/// `SIGINT`, the signal Ctrl-C sends.
30pub const SIGINT: i32 = 2;
31
32/// `SIGPIPE`, which the Rust runtime ignores and which must be restored.
33pub const SIGPIPE: i32 = 13;
34
35/// `SIGTERM`, the default `kill` signal.
36pub const SIGTERM: i32 = 15;
37
38/// The exit status a shell reports for a process killed by a signal.
39///
40/// `prk run` must reproduce this exactly, so that a caller cannot tell whether
41/// the command ran under `prk run` or directly. Anything else breaks scripts
42/// that branch on `$?`.
43pub fn exit_status_for_signal(signal: i32) -> i32 {
44    SIGNAL_EXIT_BASE + signal
45}
46
47/// Maps a child's outcome to the status `prk` should exit with.
48///
49/// `None` means the child was killed by a signal rather than exiting normally.
50pub fn child_exit_status(code: Option<i32>, signal: Option<i32>) -> i32 {
51    match (code, signal) {
52        (Some(code), _) => code,
53        (None, Some(signal)) => exit_status_for_signal(signal),
54        // A child that neither exited nor was signalled is not a state the OS
55        // reports; treat it as a generic failure rather than inventing a code.
56        (None, None) => 1,
57    }
58}
59
60/// Restores the signal state a freshly-started program expects.
61///
62/// Runs in the child, between `fork` and `exec`, so **every call in here must
63/// be async-signal-safe**. `signal` and `sigprocmask` both are; allocating,
64/// locking, or formatting a string would not be.
65///
66/// Returns an error rather than aborting so the caller can report it; a failure
67/// here is not recoverable, but it is diagnosable.
68///
69/// # Errors
70///
71/// Whatever `signal(2)` or `sigprocmask(2)` reported.
72#[cfg(unix)]
73pub fn restore_default_dispositions() -> std::io::Result<()> {
74    // SAFETY: `signal` with SIG_DFL takes no pointer arguments and cannot fail
75    // for a valid signal number other than by returning SIG_ERR, which is
76    // checked. SIGPIPE is a valid signal number on every Unix.
77    //
78    // This runs after fork in the child, so the only thread in the process is
79    // this one and there is no lock to contend for.
80    let previous = unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
81    if previous == libc::SIG_ERR {
82        return Err(std::io::Error::last_os_error());
83    }
84
85    // SAFETY: `sigemptyset` writes through a pointer to a `sigset_t` this
86    // function owns and keeps alive for the duration of both calls.
87    // `sigprocmask` reads through that same pointer and is passed a null
88    // `oldset`, which the API documents as "do not report the previous mask".
89    //
90    // `sigset_t` has no invalid bit patterns, so the zeroed value handed to
91    // `sigemptyset` is sound to construct even before it initialises it.
92    unsafe {
93        let mut empty: libc::sigset_t = std::mem::zeroed();
94        if libc::sigemptyset(&raw mut empty) != 0 {
95            return Err(std::io::Error::last_os_error());
96        }
97        if libc::sigprocmask(libc::SIG_SETMASK, &raw const empty, std::ptr::null_mut()) != 0 {
98            return Err(std::io::Error::last_os_error());
99        }
100    }
101
102    Ok(())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn signal_exit_statuses_match_the_shell_convention() {
111        assert_eq!(exit_status_for_signal(SIGINT), 130);
112        assert_eq!(exit_status_for_signal(SIGTERM), 143);
113        assert_eq!(exit_status_for_signal(SIGPIPE), 141);
114    }
115
116    #[test]
117    fn a_normal_exit_code_passes_through_unchanged() {
118        for code in [0, 1, 2, 42, 255] {
119            assert_eq!(child_exit_status(Some(code), None), code);
120        }
121    }
122
123    #[test]
124    fn a_signalled_child_reports_the_shell_status() {
125        assert_eq!(child_exit_status(None, Some(SIGTERM)), 143);
126        assert_eq!(child_exit_status(None, Some(SIGINT)), 130);
127    }
128
129    #[test]
130    fn an_exit_code_wins_over_a_signal() {
131        // Both present is contradictory; the code is the more specific fact.
132        assert_eq!(child_exit_status(Some(3), Some(SIGTERM)), 3);
133    }
134
135    #[test]
136    fn an_unknown_outcome_is_a_generic_failure_not_a_success() {
137        assert_eq!(child_exit_status(None, None), 1);
138    }
139
140    #[cfg(unix)]
141    #[test]
142    fn the_signal_numbers_match_the_platforms() {
143        assert_eq!(SIGINT, libc::SIGINT);
144        assert_eq!(SIGPIPE, libc::SIGPIPE);
145        assert_eq!(SIGTERM, libc::SIGTERM);
146    }
147
148    #[cfg(unix)]
149    #[test]
150    fn restoring_dispositions_succeeds_and_actually_changes_sigpipe() {
151        // Runs in the test process rather than after a fork, which is the only
152        // way to observe the result. The disposition is put back afterwards so
153        // the rest of the suite still sees the runtime's setting.
154        //
155        // SAFETY: reading and restoring a signal disposition in a single-
156        // threaded observation window; no pointers are involved.
157        let original = unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) };
158        assert_ne!(original, libc::SIG_ERR);
159
160        restore_default_dispositions().expect("restoring dispositions must succeed");
161
162        // SAFETY: as above. Reads back what the call above installed.
163        let now = unsafe { libc::signal(libc::SIGPIPE, original) };
164        assert_eq!(now, libc::SIG_DFL, "SIGPIPE was not restored to its default disposition");
165    }
166
167    #[cfg(unix)]
168    #[test]
169    fn restoring_dispositions_clears_the_signal_mask() {
170        // A blocked signal is inherited across exec, and the child has no way
171        // to discover that it was blocked before the fact matters.
172        //
173        // SAFETY: `blocked` and `current` are live locals for the duration of
174        // every call that writes through them, and the null `oldset` /
175        // `set` arguments are the documented "do not report" and "query only"
176        // forms.
177        let still_blocked = unsafe {
178            let mut blocked: libc::sigset_t = std::mem::zeroed();
179            libc::sigemptyset(&raw mut blocked);
180            libc::sigaddset(&raw mut blocked, libc::SIGUSR1);
181            libc::sigprocmask(libc::SIG_BLOCK, &raw const blocked, std::ptr::null_mut());
182
183            restore_default_dispositions().expect("restoring dispositions must succeed");
184
185            let mut current: libc::sigset_t = std::mem::zeroed();
186            libc::sigprocmask(libc::SIG_SETMASK, std::ptr::null(), &raw mut current);
187            libc::sigismember(&raw const current, libc::SIGUSR1)
188        };
189
190        assert_eq!(still_blocked, 0, "the signal mask was not cleared");
191    }
192}