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}