Skip to main content

bhtune_runtime/
cancel.rs

1//! A single, process-wide Ctrl+C listener shared by every await point in a tune. Registering
2//! the signal exactly once, as early in the process as possible, closes the gap where a
3//! Ctrl+C delivered while no listener happens to be alive is silently swallowed -- tokio
4//! installs a process-wide `SIGINT` handler the first time `ctrl_c()` is polled and never
5//! reverts to the OS default, so a lost signal isn't merely unhandled, it's gone.
6//!
7//! Built on [`tokio::sync::watch`] rather than `tokio_util::sync::CancellationToken` (which
8//! would need a new dependency) specifically for its per-clone "have I observed this value
9//! yet" semantics: a fresh `CtrlC::signalled` call after a signal already fired resolves
10//! immediately (including one that arrived before this handle's first `signalled()` call at
11//! all), and a *second* signal is a second, distinguishable resolution on the same handle:
12//! the first aborts the run, while another during restore forces it to give up.
13
14use std::future::Future;
15
16use tokio::sync::watch;
17
18/// A handle to the process's Ctrl+C signal, threaded explicitly through every function that
19/// needs to react to it (`execute`, `run_polling_loop`, `attempt_restore`) rather than each
20/// calling `tokio::signal::ctrl_c()` itself.
21///
22/// The CLI calls [`CtrlC::install`] exactly once, as early as possible in process startup;
23/// runtime tests instead pass `CtrlC::never` and the server passes a manually-triggered
24/// handle for each HTTP run. Tests never install a real process-wide signal handler. That
25/// matters beyond this crate's tests: once *anything* in a process calls
26/// `tokio::signal::ctrl_c()`, the OS's default "terminate on SIGINT" behavior is gone for the
27/// rest of that process, so if any unit test installed a real handler, a developer's own
28/// Ctrl+C meant to abort a hung `cargo test` run could silently disappear into an idle
29/// listener nothing is polling.
30///
31/// `pub` (rather than `pub(crate)`) so `bhtune-server` can name the type as it threads a
32/// [`CtrlC::manual`] handle through [`crate::tune::drive_report`] for an HTTP-triggered run -- see
33/// that constructor's doc comment. `CtrlC::signalled` itself deliberately stays
34/// `pub(crate)`: only code inside this crate (`execute`/`run_polling_loop`/`attempt_restore`)
35/// ever needs to *observe* a cancellation, an external caller only ever needs to *trigger*
36/// one, via a [`CtrlCHandle`].
37pub struct CtrlC {
38    rx: watch::Receiver<u32>,
39}
40
41fn install_with_signal_provider<F, Fut>(signal_provider: F) -> CtrlC
42where
43    F: Fn() -> Fut + Send + Sync + 'static,
44    Fut: Future<Output = Result<(), std::io::Error>> + Send + 'static,
45{
46    let (tx, rx) = watch::channel(0u32);
47    tokio::spawn(async move {
48        let mut count = 0u32;
49        loop {
50            if signal_provider().await.is_err() {
51                // The OS-level listener itself failed to install/poll (e.g. an exhausted
52                // signal-handling resource -- vanishingly rare). Stop rather than spin; a
53                // `CtrlC` handle simply never fires again for the rest of this process,
54                // the same observable behavior as never receiving a signal at all.
55                return;
56            }
57            count = count.wrapping_add(1);
58            if tx.send(count).is_err() {
59                // Every receiver was dropped -- nothing left to notify.
60                return;
61            }
62        }
63    });
64    CtrlC { rx }
65}
66
67impl CtrlC {
68    /// Spawns the one long-lived task that listens for Ctrl+C for the rest of the process's
69    /// life, incrementing a counter on every delivery -- see the struct doc comment for why
70    /// this must be called exactly once, and only from real process startup.
71    pub fn install() -> CtrlC {
72        // Fire-and-forget: nothing ever awaits or aborts this task, so its `JoinHandle` is
73        // simply never bound (an explicit `let _ = ...` would trip clippy's
74        // `let_underscore_future`, which can't tell this apart from a future that was meant
75        // to run but never got polled -- this one is already spawned onto the runtime the
76        // moment `tokio::spawn` returns).
77        install_with_signal_provider(tokio::signal::ctrl_c)
78    }
79
80    /// Resolves the next time Ctrl+C is delivered -- immediately, if one already arrived
81    /// since this handle last observed a change, including one that arrived before this
82    /// method was ever called (e.g. during the CLI's startup sequence).
83    pub(crate) async fn signalled(&mut self) {
84        // A real `install()`-backed handle's sender loops for the process's entire life, and
85        // a `never()` handle deliberately leaks its sender (see below) -- so `changed()`'s
86        // `Err` (every sender dropped) case is not expected to occur in practice. Treated as
87        // "never resolves" rather than unwrapped/panicking, since a hung await is a far
88        // safer failure mode here than a panic in the middle of a live tuning run.
89        while self.rx.changed().await.is_err() {
90            std::future::pending::<()>().await;
91        }
92    }
93
94    /// A handle that never fires -- for call paths that don't exercise cancellation and must
95    /// never install a real process-wide signal handler (see the struct doc comment).
96    #[cfg(test)]
97    pub(crate) fn never() -> CtrlC {
98        let (tx, rx) = watch::channel(0u32);
99        // Leaked deliberately: dropping `tx` here would make `rx.changed()` resolve
100        // immediately with an `Err`, the opposite of "never fires". `mem::forget` (not
101        // `Box::leak`) since there's no heap allocation to leak, just the drop glue to skip.
102        std::mem::forget(tx);
103        CtrlC { rx }
104    }
105
106    /// Returns a fresh `(CtrlC, Sender)` pair so a test can manually `.send(..)` to simulate a
107    /// Ctrl+C press deterministically, without a real OS signal or subprocess.
108    #[cfg(test)]
109    pub(crate) fn test_pair() -> (CtrlC, watch::Sender<u32>) {
110        let (tx, rx) = watch::channel(0u32);
111        (CtrlC { rx }, tx)
112    }
113
114    /// Returns a fresh `(CtrlC, CtrlCHandle)` pair for a caller with no real OS Ctrl+C
115    /// keypress to listen for at all -- `bhtune-server`'s background tune task, which needs
116    /// an HTTP request (`POST /api/runs/{id}/cancel`, or graceful shutdown) to be able to
117    /// trigger the exact same cancellation an interactive Ctrl+C would.
118    ///
119    /// Deliberately **not** `#[cfg(test)]`-gated, unlike `CtrlC::never`/`CtrlC::test_pair`
120    /// above: those exist purely so this crate's own unit tests can avoid installing a real
121    /// process-wide signal handler, but `manual()` never touches
122    /// `tokio::signal`/[`CtrlC::install`] at all, so calling it from production code any
123    /// number of times (once per in-flight run) carries none of that risk.
124    pub fn manual() -> (CtrlC, CtrlCHandle) {
125        let (tx, rx) = watch::channel(0u32);
126        (CtrlC { rx }, CtrlCHandle { tx })
127    }
128}
129
130/// A trigger for a [`CtrlC`] handle created via [`CtrlC::manual`] -- the HTTP-triggered
131/// equivalent of a real Ctrl+C keypress. Deliberately a thin wrapper around the same
132/// `watch::Sender<u32>` mechanism `#[cfg(test)]`'s `test_pair()` already uses internally,
133/// rather than a second, parallel cancellation mechanism: `CtrlC::signalled` can't tell the
134/// two apart, so `execute`/`run_polling_loop`/`attempt_restore` need no changes at all to
135/// support HTTP-triggered cancellation.
136///
137/// `Clone` so a caller can store one copy in a run registry (to answer a later
138/// `POST /api/runs/{id}/cancel`) while another copy is held by whatever's waiting to trigger
139/// it on graceful shutdown.
140#[derive(Clone)]
141pub struct CtrlCHandle {
142    tx: watch::Sender<u32>,
143}
144
145impl CtrlCHandle {
146    /// Requests cancellation, exactly as if Ctrl+C had been pressed. Safe to call more than
147    /// once -- a second call lets a caller model a "second Ctrl+C" hard-exit request arriving
148    /// during an already-in-flight restore -- and safe to call after the paired
149    /// [`CtrlC`] has already been dropped (the run this handle was for has already ended):
150    /// [`watch::Sender::send_modify`] never fails, unlike `send`, so there is nothing to
151    /// propagate or ignore.
152    pub fn trigger(&self) {
153        self.tx.send_modify(|count| *count = count.wrapping_add(1));
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::time::Duration;
161
162    async fn assert_not_signalled(mut ctrl_c: CtrlC) {
163        tokio::select! {
164            () = ctrl_c.signalled() => panic!("signalled() must not resolve"),
165            () = tokio::time::sleep(Duration::from_millis(20)) => {}
166        }
167    }
168
169    #[tokio::test]
170    async fn never_does_not_resolve_signalled_even_after_a_yield() {
171        assert_not_signalled(CtrlC::never()).await;
172    }
173
174    #[tokio::test]
175    async fn test_pair_resolves_signalled_after_a_manual_send() {
176        let (mut ctrl_c, tx) = CtrlC::test_pair();
177        tx.send(1).unwrap();
178        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
179            .await
180            .expect("signalled() should resolve promptly after a manual send");
181    }
182
183    #[tokio::test]
184    async fn signalled_resolves_again_for_a_second_send_on_the_same_handle() {
185        let (mut ctrl_c, tx) = CtrlC::test_pair();
186        tx.send(1).unwrap();
187        ctrl_c.signalled().await;
188        tx.send(2).unwrap();
189        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
190            .await
191            .expect("a second send should resolve signalled() again");
192    }
193
194    #[tokio::test]
195    async fn a_send_before_the_receiver_ever_awaits_is_still_observed() {
196        let (mut ctrl_c, tx) = CtrlC::test_pair();
197        tx.send(1).unwrap();
198        // No intervening await -- `watch`'s "already changed, not yet observed by this
199        // receiver" semantics must still resolve `signalled()` immediately, matching a real
200        // Ctrl+C delivered before a caller ever starts waiting for it.
201        tokio::time::timeout(Duration::from_millis(50), ctrl_c.signalled())
202            .await
203            .expect("a send delivered before signalled() was ever called must still be seen");
204    }
205
206    #[tokio::test]
207    async fn signal_listener_stops_when_signal_provider_fails() {
208        let called = std::sync::Arc::new(tokio::sync::Notify::new());
209        let provider_called = std::sync::Arc::clone(&called);
210        let _ctrl_c = install_with_signal_provider(move || {
211            provider_called.notify_one();
212            async { Err(std::io::Error::other("test signal provider failure")) }
213        });
214
215        tokio::time::timeout(Duration::from_millis(200), called.notified())
216            .await
217            .expect("the injected signal provider should be called");
218    }
219
220    #[tokio::test]
221    async fn signal_listener_notifies_receivers_for_each_successful_signal() {
222        let (ready, release) = (
223            std::sync::Arc::new(tokio::sync::Notify::new()),
224            std::sync::Arc::new(tokio::sync::Notify::new()),
225        );
226        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
227        let provider_ready = ready.clone();
228        let provider_release = release.clone();
229        let provider_calls = calls.clone();
230        let mut ctrl_c = install_with_signal_provider(move || {
231            provider_ready.notify_one();
232            let provider_release = provider_release.clone();
233            let call = provider_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
234            async move {
235                if call < 2 {
236                    provider_release.notified().await;
237                    Ok(())
238                } else {
239                    Err(std::io::Error::other("stop test listener"))
240                }
241            }
242        });
243
244        for _ in 0..2 {
245            tokio::time::timeout(Duration::from_millis(200), ready.notified())
246                .await
247                .expect("the injected provider should wait for the next signal");
248            release.notify_one();
249            tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
250                .await
251                .expect("the listener should notify its receiver");
252        }
253    }
254
255    #[tokio::test]
256    async fn signal_listener_stops_when_all_receivers_are_dropped() {
257        let started = std::sync::Arc::new(tokio::sync::Notify::new());
258        let release = std::sync::Arc::new(tokio::sync::Notify::new());
259        let provider_started = std::sync::Arc::clone(&started);
260        let provider_release = std::sync::Arc::clone(&release);
261        let ctrl_c = install_with_signal_provider(move || {
262            provider_started.notify_one();
263            let provider_release = std::sync::Arc::clone(&provider_release);
264            async move {
265                provider_release.notified().await;
266                Ok(())
267            }
268        });
269
270        tokio::time::timeout(Duration::from_millis(200), started.notified())
271            .await
272            .expect("the injected signal provider should start");
273        drop(ctrl_c);
274        release.notify_one();
275        tokio::task::yield_now().await;
276    }
277
278    #[tokio::test]
279    async fn signalled_waits_forever_when_all_senders_are_dropped() {
280        let (tx, rx) = watch::channel(0u32);
281        drop(tx);
282        let mut ctrl_c = CtrlC { rx };
283        let result = tokio::time::timeout(Duration::from_millis(20), ctrl_c.signalled()).await;
284        assert!(result.is_err());
285    }
286
287    // `install()`'s own real-signal-handling behavior deliberately has no in-process test
288    // here: raising a real `SIGINT` against this test binary before `install()`'s spawned
289    // task has actually reached `tokio::signal::ctrl_c().await` (registering the OS-level
290    // handler) would hit the OS default disposition instead -- process termination -- and
291    // there is no race-free way to know that registration has happened from outside the
292    // task. `tests/ctrlc_abort.rs` already proves `install()` against a real `SIGINT` safely,
293    // by sending it to a dedicated child *process* rather than this shared test binary.
294
295    #[tokio::test]
296    async fn manual_resolves_signalled_after_a_trigger() {
297        let (mut ctrl_c, handle) = CtrlC::manual();
298        handle.trigger();
299        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
300            .await
301            .expect("signalled() should resolve promptly after trigger()");
302    }
303
304    #[tokio::test]
305    async fn manual_does_not_resolve_signalled_before_any_trigger() {
306        let (ctrl_c, _handle) = CtrlC::manual();
307        assert_not_signalled(ctrl_c).await;
308    }
309
310    #[tokio::test]
311    async fn no_signal_assertion_panics_when_a_signal_arrives() {
312        let (ctrl_c, handle) = CtrlC::manual();
313        handle.trigger();
314        let error = tokio::spawn(assert_not_signalled(ctrl_c))
315            .await
316            .unwrap_err();
317        assert!(error.is_panic());
318    }
319
320    #[tokio::test]
321    async fn manual_handle_clone_triggers_the_same_ctrl_c() {
322        let (mut ctrl_c, handle) = CtrlC::manual();
323        let cloned = handle.clone();
324        cloned.trigger();
325        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
326            .await
327            .expect("a clone's trigger() should resolve the original CtrlC's signalled()");
328    }
329
330    #[tokio::test]
331    async fn manual_handle_trigger_is_safe_to_call_after_ctrl_c_is_dropped() {
332        let (ctrl_c, handle) = CtrlC::manual();
333        drop(ctrl_c);
334        // Must not panic even though every receiver is gone.
335        handle.trigger();
336    }
337
338    #[tokio::test]
339    async fn manual_handle_second_trigger_resolves_signalled_again() {
340        let (mut ctrl_c, handle) = CtrlC::manual();
341        handle.trigger();
342        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
343            .await
344            .expect("the first trigger should resolve signalled()");
345        handle.trigger();
346        tokio::time::timeout(Duration::from_millis(200), ctrl_c.signalled())
347            .await
348            .expect("a second trigger() should resolve signalled() again");
349    }
350}