bynk/stop.rs
1//! Stopping `bynk dev` from outside (#1955).
2//!
3//! Without a handler, a SIGTERM or SIGINT sent to `bynk dev` alone (by a CI
4//! step, a test harness, an editor task) killed it on the spot, and its
5//! wranglers and their `workerd`s ran on with their ports bound. A Ctrl-C in a
6//! terminal never showed it, because the terminal signals the whole foreground
7//! group, wranglers included.
8//!
9//! [`install`] replaces the default action with one that only records the
10//! signal. `bynk dev`'s watch loop sees it on its next tick and stops its
11//! contexts the way it does when one of them exits: SIGTERM to each wrangler,
12//! then the sweep of what they leave behind ([`crate::sweep`]). The wranglers
13//! stay in `bynk dev`'s process group, because the sweep finds orphaned
14//! `workerd`s by that group and a Ctrl-C must still reach them all.
15//!
16//! The handler is one-shot (`SA_RESETHAND`): a second Ctrl-C while the
17//! contexts are stopping takes the default action and ends `bynk dev` at once.
18//!
19//! Unix only. On Windows each wrangler's job object holds its tree (#1762), and
20//! [`install`] does nothing.
21
22use std::sync::atomic::{AtomicI32, Ordering};
23
24/// The signal received since [`install`], or 0 for none.
25static RECEIVED: AtomicI32 = AtomicI32::new(0);
26
27/// Record SIGINT and SIGTERM instead of dying of them, for [`received`] to
28/// report. Children spawned afterwards are unaffected: `exec` resets a caught
29/// signal to its default action.
30pub fn install() {
31 #[cfg(unix)]
32 for sig in [libc::SIGINT, libc::SIGTERM] {
33 // SAFETY: `record` only stores to an atomic, which is
34 // async-signal-safe, and `action` is fully initialised before use.
35 unsafe {
36 let mut action: libc::sigaction = std::mem::zeroed();
37 action.sa_sigaction = record as extern "C" fn(libc::c_int) as libc::sighandler_t;
38 action.sa_flags = libc::SA_RESTART | libc::SA_RESETHAND;
39 libc::sigemptyset(&mut action.sa_mask);
40 if libc::sigaction(sig, &action, std::ptr::null_mut()) != 0 {
41 eprintln!(
42 "bynk dev: could not install a handler for signal {sig}; stopping \
43 `bynk dev` with it may leave its workerd processes running."
44 );
45 }
46 }
47 }
48}
49
50#[cfg(unix)]
51extern "C" fn record(sig: libc::c_int) {
52 RECEIVED.store(sig, Ordering::SeqCst);
53}
54
55/// The signal that asked `bynk dev` to stop, if one has arrived.
56pub fn received() -> Option<i32> {
57 match RECEIVED.load(Ordering::SeqCst) {
58 0 => None,
59 sig => Some(sig),
60 }
61}
62
63/// #2055: SIGINT and SIGTERM recorded rather than fatal for as long as the
64/// returned guard lives, then the dispositions in force before restored.
65///
66/// `bynk deploy` holds one while a first deploy's `wrangler deploy
67/// --secrets-file` runs, so a Ctrl-C (which the terminal sends to the whole
68/// foreground group, wrangler included) or a SIGTERM sent to `bynk` alone
69/// cannot end `bynk` before it removes the secrets file. A caught signal is
70/// reset to its default by `exec`, so wrangler itself still dies of them as it
71/// would have. Unlike [`install`], the handler is not one-shot: a second
72/// Ctrl-C must not end `bynk` with the file on disk either. Only SIGKILL can,
73/// and the next deploy's sweep removes what that leaves.
74///
75/// Clears any earlier recorded signal, so [`received`] reports only what
76/// arrived under this guard. On Windows it changes nothing.
77#[must_use = "the signals are restored when the guard drops"]
78pub(crate) fn defer() -> Deferred {
79 RECEIVED.store(0, Ordering::SeqCst);
80 #[cfg(unix)]
81 {
82 let mut previous: [Option<libc::sigaction>; 2] = [None, None];
83 for (slot, sig) in previous.iter_mut().zip([libc::SIGINT, libc::SIGTERM]) {
84 // SAFETY: `record` only stores to an atomic, which is
85 // async-signal-safe; both structs are fully initialised before use,
86 // and `old` is only kept when the call succeeded.
87 unsafe {
88 let mut action: libc::sigaction = std::mem::zeroed();
89 action.sa_sigaction = record as extern "C" fn(libc::c_int) as libc::sighandler_t;
90 action.sa_flags = libc::SA_RESTART;
91 libc::sigemptyset(&mut action.sa_mask);
92 let mut old: libc::sigaction = std::mem::zeroed();
93 if libc::sigaction(sig, &action, &mut old) == 0 {
94 *slot = Some(old);
95 }
96 }
97 }
98 Deferred { previous }
99 }
100 #[cfg(not(unix))]
101 Deferred {}
102}
103
104/// The guard [`defer`] returns. Dropping it puts back the SIGINT and SIGTERM
105/// dispositions [`defer`] replaced.
106pub(crate) struct Deferred {
107 #[cfg(unix)]
108 previous: [Option<libc::sigaction>; 2],
109}
110
111impl Drop for Deferred {
112 fn drop(&mut self) {
113 #[cfg(unix)]
114 for (old, sig) in self.previous.iter().zip([libc::SIGINT, libc::SIGTERM]) {
115 if let Some(old) = old {
116 // SAFETY: `old` is the disposition `sigaction` returned.
117 unsafe {
118 libc::sigaction(sig, old, std::ptr::null_mut());
119 }
120 }
121 }
122 }
123}
124
125/// Serialises the tests that change this process's signal dispositions or
126/// signal it, which would otherwise race in the shared test binary.
127#[cfg(test)]
128pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
129
130/// Undo [`install`] and forget any recorded signal, so a test that installs
131/// the handler leaves nothing behind in the shared test process.
132#[cfg(test)]
133pub(crate) fn reset() {
134 #[cfg(unix)]
135 for sig in [libc::SIGINT, libc::SIGTERM] {
136 // SAFETY: restoring the default disposition.
137 unsafe {
138 libc::signal(sig, libc::SIG_DFL);
139 }
140 }
141 RECEIVED.store(0, Ordering::SeqCst);
142}
143
144/// The exit byte for a session stopped by `sig`, as [`crate::shell::exit_status_byte`]
145/// maps a child's signal death: SIGINT is a clean stop (it is what Ctrl-C
146/// sends), anything else `128 + sig`, the shell convention.
147pub fn exit_byte(sig: i32) -> u8 {
148 // SIGINT is 2 on every Unix.
149 if sig == 2 {
150 0
151 } else {
152 128u8.saturating_add(sig.clamp(0, 127) as u8)
153 }
154}
155
156/// How to name `sig` in `bynk dev`'s stop notice.
157pub fn name(sig: i32) -> String {
158 match sig {
159 2 => "SIGINT".to_string(),
160 15 => "SIGTERM".to_string(),
161 n => format!("signal {n}"),
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 /// The handler `sig` currently has.
170 #[cfg(unix)]
171 fn disposition(sig: libc::c_int) -> libc::sighandler_t {
172 // SAFETY: a query; `current` is written by the call.
173 unsafe {
174 let mut current: libc::sigaction = std::mem::zeroed();
175 assert_eq!(libc::sigaction(sig, std::ptr::null(), &mut current), 0);
176 current.sa_sigaction
177 }
178 }
179
180 #[cfg(unix)]
181 #[test]
182 fn a_deferred_signal_is_recorded_and_the_old_disposition_comes_back() {
183 // #2055: what keeps `bynk deploy` alive to remove a secrets file.
184 let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
185 let before = [disposition(libc::SIGINT), disposition(libc::SIGTERM)];
186 {
187 let _deferred = defer();
188 assert_eq!(received(), None, "nothing carried over");
189 // Without the handler this would end the test binary.
190 // SAFETY: signalling this process, whose handler only records.
191 unsafe {
192 libc::raise(libc::SIGTERM);
193 libc::raise(libc::SIGINT);
194 }
195 assert!(received().is_some(), "the signals were recorded");
196 // SAFETY: as above; the handler is not one-shot.
197 unsafe {
198 libc::raise(libc::SIGINT);
199 }
200 }
201 assert_eq!(
202 [disposition(libc::SIGINT), disposition(libc::SIGTERM)],
203 before,
204 "the dispositions in force before are restored"
205 );
206 RECEIVED.store(0, Ordering::SeqCst);
207 }
208
209 #[test]
210 fn a_signal_stop_exits_like_a_signalled_child() {
211 assert_eq!(exit_byte(2), 0, "Ctrl-C is a clean stop");
212 assert_eq!(exit_byte(15), 143);
213 }
214}