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}