Skip to main content

fallow_cli/signal/
mod.rs

1//! Process-wide signal handling and scoped child-process registry.
2//!
3//! On SIGINT or SIGTERM (Unix) and the equivalent console-control events on
4//! Windows, fallow's default unwind drops `std::process::Child` handles
5//! without killing the underlying children. The `fallow-cov` sidecar,
6//! `npm install -g`, and self-invoked `fallow health` can run for minutes
7//! and accumulate as orphan processes when the user hits Ctrl+C.
8//!
9//! This module installs a single handler (see `install_handlers`) that on
10//! signal delivery: kills every `ScopedChild` currently registered, drains
11//! them with a bounded budget (500ms Unix, 1500ms Windows), and exits with
12//! the conventional 128+signum exit code (130 for SIGINT, 143 for SIGTERM).
13//!
14//! Watch mode opts into cooperative shutdown via `set_graceful_mode`: the
15//! handler then only flips the shutdown flag and returns, letting the watch
16//! loop exit cleanly with code 0 because Ctrl+C is its documented
17//! termination path. Other commands keep the forceful 128+signum behavior.
18//!
19//! See `.plans/issue-477-signal-handlers.md` for the design rationale and
20//! `crates/lsp/src/main.rs` for the LSP-side cooperative cancellation.
21
22pub mod registry;
23pub mod scoped_child;
24
25#[cfg(unix)]
26mod unix;
27#[cfg(windows)]
28mod windows;
29
30use std::sync::OnceLock;
31use std::sync::atomic::{AtomicBool, Ordering};
32
33pub use scoped_child::ScopedChild;
34
35/// True once a termination signal has been observed.
36static SHUTDOWN: AtomicBool = AtomicBool::new(false);
37
38/// True when a cooperative consumer (`fallow watch`) is active. The handler
39/// then flips `SHUTDOWN` and returns instead of killing children and
40/// exiting; the consumer is responsible for clean teardown.
41static GRACEFUL: AtomicBool = AtomicBool::new(false);
42
43/// Idempotency guard for `install_handlers`. Repeated calls (e.g. when
44/// `run_watch` reinstalls the handler) are silently no-ops.
45static INSTALLED: OnceLock<()> = OnceLock::new();
46
47/// Install the signal handler. Idempotent; safe to call multiple times.
48/// Returns the original error from the underlying primitive on first call
49/// failure.
50pub fn install_handlers() -> std::io::Result<()> {
51    if INSTALLED.get().is_some() {
52        return Ok(());
53    }
54    let result = platform_install();
55    if result.is_ok() {
56        let _ = INSTALLED.set(());
57    }
58    result
59}
60
61/// True after a signal has been observed. Read by long-running loops
62/// (currently `fallow watch`) to break out cooperatively.
63pub fn is_shutting_down() -> bool {
64    SHUTDOWN.load(Ordering::SeqCst)
65}
66
67/// Enter cooperative shutdown mode. Subsequent signals set `SHUTDOWN`
68/// without killing children or calling `exit()`. The caller is responsible
69/// for polling `is_shutting_down()` and exiting cleanly.
70pub fn set_graceful_mode() {
71    GRACEFUL.store(true, Ordering::SeqCst);
72}
73
74/// Leave cooperative shutdown mode. Subsequent signals revert to the
75/// forceful behavior (kill registered children, `exit(128 + signum)`).
76pub fn clear_graceful_mode() {
77    GRACEFUL.store(false, Ordering::SeqCst);
78}
79
80/// RAII guard that calls `set_graceful_mode` on construction and
81/// `clear_graceful_mode` on drop. Used by `run_watch` so any return path
82/// (success, panic-with-unwind in debug, early return on config error)
83/// restores forceful-exit behavior for the next command.
84pub struct GracefulModeGuard;
85
86impl GracefulModeGuard {
87    pub fn new() -> Self {
88        set_graceful_mode();
89        Self
90    }
91}
92
93impl Default for GracefulModeGuard {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl Drop for GracefulModeGuard {
100    fn drop(&mut self) {
101        clear_graceful_mode();
102    }
103}
104
105#[cfg(unix)]
106fn platform_install() -> std::io::Result<()> {
107    unix::install()
108}
109
110#[cfg(windows)]
111fn platform_install() -> std::io::Result<()> {
112    windows::install()
113}
114
115#[cfg(not(any(unix, windows)))]
116fn platform_install() -> std::io::Result<()> {
117    // No-op on unknown platforms; ScopedChild's Drop still cleans up
118    // normal early-return paths but signal-driven cleanup is unavailable.
119    Ok(())
120}
121
122/// Mark shutdown, drain the registry (kills every registered child
123/// regardless of mode so in-flight subprocesses do not survive the
124/// signal), then either exit (default) or return for cooperative
125/// consumers in graceful mode (`fallow watch`).
126///
127/// Graceful mode MUST still drain children: watch's `analyze_and_
128/// report` spawns git subprocesses (via `fallow_core::changed_files`
129/// and `fallow_core::churn`) that need reaping mid-analysis. Without
130/// drain, a Ctrl+C during analysis would let the parent return from
131/// the inner pass only after every git child completed naturally,
132/// defeating the entire "Ctrl+C reaps in-flight git work" contract.
133/// Invoked by the platform-specific handler thread.
134fn handle_signal(exit_code: i32) {
135    SHUTDOWN.store(true, Ordering::SeqCst);
136    registry::drain_and_kill();
137    if GRACEFUL.load(Ordering::SeqCst) {
138        return;
139    }
140    #[expect(
141        clippy::exit,
142        reason = "signal handler MUST terminate the process; that is the entire point of the path"
143    )]
144    std::process::exit(exit_code);
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn graceful_mode_guard_sets_and_clears() {
153        assert!(!GRACEFUL.load(Ordering::SeqCst));
154        {
155            let _g = GracefulModeGuard::new();
156            assert!(GRACEFUL.load(Ordering::SeqCst));
157        }
158        assert!(!GRACEFUL.load(Ordering::SeqCst));
159    }
160
161    #[test]
162    fn install_handlers_is_idempotent() {
163        // The first call may succeed or fail depending on test ordering
164        // (signal disposition is process-global), but the second call MUST
165        // be a no-op and return Ok.
166        let _ = install_handlers();
167        assert!(install_handlers().is_ok());
168    }
169}