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}