Skip to main content

rightkit_daemon/
stop.rs

1//! Stop fan-in for a daemon host (issue #375): one [`StopSource`] that several inputs may
2//! trigger (an OS stop signal, the parent closing this process's stdin, or the program itself),
3//! and [`StopToken`]s that the host's threads wait on. The first cause wins and later triggers
4//! are ignored.
5//!
6//! Signals: [`stop_on_signals`] installs handlers for SIGINT and SIGTERM on Unix and the console
7//! control handler on Windows (Ctrl+C, Ctrl+Break, close). The handler only records the event in a
8//! static atomic (the one thing a signal handler may do here); a watcher thread turns that into a
9//! trigger, polling every 50 ms, and exits once the source is stopped. Other platforms return
10//! `Unsupported`.
11//!
12//! Stdin: [`stop_on_stdin_close`] triggers the source when stdin reaches end of file (see
13//! [`crate::private::watch_stdin_eof`]).
14
15use std::io;
16use std::sync::{Arc, Condvar, Mutex};
17use std::thread::JoinHandle;
18use std::time::{Duration, Instant};
19
20/// Why the stop was triggered. The first trigger's cause is the one reported.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum StopCause {
23    /// SIGINT, Ctrl+C or Ctrl+Break.
24    Interrupt,
25    /// SIGTERM or the console close event.
26    Terminate,
27    /// This process's stdin reached end of file: the parent went away.
28    StdinClosed,
29    /// The parent process this one was fenced to (pid + start marker) is gone.
30    ParentGone,
31    /// The program asked for the stop.
32    Requested,
33}
34
35struct Inner {
36    state: Mutex<Option<StopCause>>,
37    changed: Condvar,
38}
39
40/// The trigger side. Clone it to give inputs their own handle; every clone triggers the same stop.
41#[derive(Clone)]
42pub struct StopSource {
43    inner: Arc<Inner>,
44}
45
46/// The waiting side. Cheap to clone; it sees the stop that its source triggers.
47#[derive(Clone)]
48pub struct StopToken {
49    inner: Arc<Inner>,
50}
51
52impl Default for StopSource {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl StopSource {
59    pub fn new() -> Self {
60        Self {
61            inner: Arc::new(Inner {
62                state: Mutex::new(None),
63                changed: Condvar::new(),
64            }),
65        }
66    }
67
68    /// A token that observes this source.
69    pub fn token(&self) -> StopToken {
70        StopToken {
71            inner: self.inner.clone(),
72        }
73    }
74
75    /// Trigger the stop. Returns `true` for the call that set it; later calls change nothing.
76    pub fn trigger(&self, cause: StopCause) -> bool {
77        let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
78        if state.is_some() {
79            return false;
80        }
81        *state = Some(cause);
82        self.inner.changed.notify_all();
83        true
84    }
85}
86
87impl StopToken {
88    /// The cause, once the stop has been triggered.
89    pub fn cause(&self) -> Option<StopCause> {
90        *self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
91    }
92
93    pub fn is_stopped(&self) -> bool {
94        self.cause().is_some()
95    }
96
97    /// Wait up to `timeout` for the stop. Returns its cause as soon as it is triggered, or `None`
98    /// when the timeout passes first. Returns at once when the stop is already set.
99    pub fn wait(&self, timeout: Duration) -> Option<StopCause> {
100        let deadline = Instant::now() + timeout;
101        let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
102        loop {
103            if let Some(cause) = *state {
104                return Some(cause);
105            }
106            let now = Instant::now();
107            if now >= deadline {
108                return None;
109            }
110            let (next, _) = self
111                .inner
112                .changed
113                .wait_timeout(state, deadline - now)
114                .unwrap_or_else(|e| e.into_inner());
115            state = next;
116        }
117    }
118}
119
120/// Static slot the signal handler writes; 0 means none pending.
121#[cfg(unix)]
122static PENDING_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
123
124#[cfg(unix)]
125const SIGINT: i32 = 2;
126#[cfg(unix)]
127const SIGTERM: i32 = 15;
128
129#[cfg(unix)]
130extern "C" fn on_unix_signal(signal: i32) {
131    // Only an atomic store here: the one operation a signal handler may safely do.
132    PENDING_SIGNAL.store(signal, std::sync::atomic::Ordering::SeqCst);
133}
134
135#[cfg(unix)]
136extern "C" {
137    fn signal(signum: i32, handler: usize) -> usize;
138}
139
140#[cfg(windows)]
141static PENDING_CONSOLE_EVENT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
142
143#[cfg(windows)]
144unsafe extern "system" fn on_console_event(event: u32) -> i32 {
145    PENDING_CONSOLE_EVENT.store(event + 1, std::sync::atomic::Ordering::SeqCst);
146    1 // handled: the default handler (which ends the process) is not run
147}
148
149#[cfg(windows)]
150extern "system" {
151    fn SetConsoleCtrlHandler(
152        handler: Option<unsafe extern "system" fn(u32) -> i32>,
153        add: i32,
154    ) -> i32;
155}
156
157/// Install OS stop-signal handlers that trigger `source` (see the module docs). The watcher thread
158/// ends when the source is stopped by any input.
159pub fn stop_on_signals(source: &StopSource) -> io::Result<JoinHandle<()>> {
160    install_os_handlers()?;
161    let source = source.clone();
162    std::thread::Builder::new()
163        .name("rk-stop-signals".into())
164        .spawn(move || loop {
165            if source.token().is_stopped() {
166                break;
167            }
168            if let Some(cause) = take_pending_signal() {
169                source.trigger(cause);
170                break;
171            }
172            std::thread::sleep(Duration::from_millis(50));
173        })
174}
175
176/// Trigger `source` when this process's stdin reaches end of file.
177pub fn stop_on_stdin_close(source: &StopSource) -> JoinHandle<()> {
178    let source = source.clone();
179    crate::private::watch_stdin_eof(move || {
180        source.trigger(StopCause::StdinClosed);
181    })
182}
183
184/// A parent-process identity fence: the parent's pid pinned to its OS start marker, so a
185/// different process that later reuses the pid is never mistaken for the parent. A child
186/// whose parent cannot hold its stdin open (or must not rely on it alone) captures this at
187/// start and stops itself when [`parent_gone`](Self::parent_gone) turns true; conversely a
188/// reused pid alone can neither keep the child alive nor claim to be its parent.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct ParentFence {
191    pid: u32,
192    started: Option<(u64, u64)>,
193}
194
195impl ParentFence {
196    /// Capture process `pid`'s identity now (its start marker as the OS reports it;
197    /// `None` when unreadable, in which case the fence falls back to pid liveness and a
198    /// reused pid cannot be told apart — see [`parent_gone`](Self::parent_gone)).
199    pub fn capture(pid: u32) -> Self {
200        Self {
201            pid,
202            started: crate::host_identity::process_start_marker(pid),
203        }
204    }
205
206    /// A fence from a pid and a start marker captured elsewhere (for example handed to
207    /// the child at spawn, before any reuse could happen).
208    pub fn from_parts(pid: u32, started: Option<(u64, u64)>) -> Self {
209        Self { pid, started }
210    }
211
212    pub fn pid(&self) -> u32 {
213        self.pid
214    }
215
216    /// The captured start marker, when the OS reported one.
217    pub fn started(&self) -> Option<(u64, u64)> {
218        self.started
219    }
220
221    /// True when the fenced parent is gone: with a captured start marker, when the pid's
222    /// current marker differs (the process exited, or the pid was reused by another
223    /// process); without one, when the pid is no longer alive.
224    pub fn parent_gone(&self) -> bool {
225        parent_gone(
226            self.started,
227            crate::host_identity::process_start_marker(self.pid),
228            crate::transport::pid_alive(self.pid),
229        )
230    }
231}
232
233/// The pure fence decision, split out for tests.
234fn parent_gone(captured: Option<(u64, u64)>, current: Option<(u64, u64)>, pid_alive: bool) -> bool {
235    match captured {
236        // The marker pins the pid to one process: any difference — gone, or a reused pid
237        // with a different start — means the parent itself is gone.
238        Some(marker) => current != Some(marker),
239        None => !pid_alive,
240    }
241}
242
243/// Trigger `source` with [`StopCause::ParentGone`] once `fence`'s parent is gone. Polls
244/// every `poll` (raised to at least 10 ms); the watcher thread ends when the source is
245/// stopped by any input.
246pub fn stop_when_parent_gone(
247    source: &StopSource,
248    fence: ParentFence,
249    poll: Duration,
250) -> JoinHandle<()> {
251    let source = source.clone();
252    let poll = poll.max(Duration::from_millis(10));
253    std::thread::spawn(move || loop {
254        if source.token().is_stopped() {
255            break;
256        }
257        if fence.parent_gone() {
258            source.trigger(StopCause::ParentGone);
259            break;
260        }
261        std::thread::sleep(poll);
262    })
263}
264
265#[cfg(unix)]
266fn install_os_handlers() -> io::Result<()> {
267    for sig in [SIGINT, SIGTERM] {
268        let handler = on_unix_signal as extern "C" fn(i32) as usize;
269        // SAFETY: `signal` is the C library function; the handler is an `extern "C"` function
270        // that only stores to an atomic. SIG_ERR is `usize::MAX` on both Linux and macOS.
271        let previous = unsafe { signal(sig, handler) };
272        if previous == usize::MAX {
273            return Err(io::Error::last_os_error());
274        }
275    }
276    Ok(())
277}
278
279#[cfg(unix)]
280fn take_pending_signal() -> Option<StopCause> {
281    match PENDING_SIGNAL.swap(0, std::sync::atomic::Ordering::SeqCst) {
282        0 => None,
283        SIGINT => Some(StopCause::Interrupt),
284        _ => Some(StopCause::Terminate),
285    }
286}
287
288#[cfg(windows)]
289fn install_os_handlers() -> io::Result<()> {
290    // SAFETY: the handler is an `unsafe extern "system"` function that only stores to an atomic.
291    let ok = unsafe { SetConsoleCtrlHandler(Some(on_console_event), 1) };
292    if ok == 0 {
293        return Err(io::Error::last_os_error());
294    }
295    Ok(())
296}
297
298#[cfg(windows)]
299fn take_pending_signal() -> Option<StopCause> {
300    // Console events: 0 Ctrl+C and 1 Ctrl+Break are interrupts; 2 (close) and the logoff and
301    // shutdown events are terminations. The stored value is event + 1, so 0 is "none".
302    match PENDING_CONSOLE_EVENT.swap(0, std::sync::atomic::Ordering::SeqCst) {
303        0 => None,
304        1 | 2 => Some(StopCause::Interrupt),
305        _ => Some(StopCause::Terminate),
306    }
307}
308
309#[cfg(not(any(unix, windows)))]
310fn install_os_handlers() -> io::Result<()> {
311    Err(io::Error::new(
312        io::ErrorKind::Unsupported,
313        "stop signals are not implemented on this platform",
314    ))
315}
316
317#[cfg(not(any(unix, windows)))]
318fn take_pending_signal() -> Option<StopCause> {
319    None
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn the_first_trigger_wins() {
328        let source = StopSource::new();
329        assert!(source.trigger(StopCause::Terminate));
330        assert!(!source.trigger(StopCause::StdinClosed));
331        assert_eq!(source.token().cause(), Some(StopCause::Terminate));
332    }
333
334    #[test]
335    fn a_reused_parent_pid_is_gone_but_the_same_process_is_not() {
336        let mine = Some((10, 20));
337        // Same pid, same start marker: the parent itself is alive.
338        assert!(!parent_gone(mine, mine, true));
339        // Same pid, different start marker: the pid was reused; the parent is gone even
340        // though the pid is alive, so the reuser cannot stop (or spare) the child.
341        assert!(parent_gone(mine, Some((99, 1)), true));
342        // No process at the pid any more.
343        assert!(parent_gone(mine, None, false));
344        // Without a captured marker the fence is pid liveness only.
345        assert!(!parent_gone(None, None, true));
346        assert!(parent_gone(None, None, false));
347    }
348
349    #[test]
350    fn a_fence_on_this_process_is_not_gone_and_a_watcher_fires_for_a_dead_parent() {
351        let fence = ParentFence::capture(std::process::id());
352        assert_eq!(fence.pid(), std::process::id());
353        assert!(!fence.parent_gone());
354        // A fence whose marker cannot match the (live) test process reads as gone: the
355        // pid-reuse shape, driven through the watcher end to end.
356        let reused = ParentFence::from_parts(std::process::id(), Some((u64::MAX, u64::MAX)));
357        if fence.started().is_some() {
358            assert!(reused.parent_gone());
359            let source = StopSource::new();
360            let token = source.token();
361            let watcher = stop_when_parent_gone(&source, reused, Duration::from_millis(10));
362            assert_eq!(
363                token.wait(Duration::from_secs(5)),
364                Some(StopCause::ParentGone)
365            );
366            watcher.join().unwrap();
367        }
368    }
369
370    #[test]
371    fn a_wait_times_out_until_triggered() {
372        let source = StopSource::new();
373        let token = source.token();
374        assert_eq!(token.wait(Duration::from_millis(5)), None);
375        assert!(!token.is_stopped());
376        let clone = source.clone();
377        let t = std::thread::spawn(move || {
378            std::thread::sleep(Duration::from_millis(10));
379            clone.trigger(StopCause::Requested);
380        });
381        assert_eq!(
382            token.wait(Duration::from_secs(5)),
383            Some(StopCause::Requested)
384        );
385        t.join().unwrap();
386    }
387}