Skip to main content

bhtune_cli/
cancel.rs

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