Skip to main content

bhtune_server/
active_run.rs

1//! [`ActiveRun`]: tracks every tune run currently executing in a background task, so
2//! [`crate::routes::runs`] can cancel one specific run and graceful shutdown can cancel and
3//! wait for all in-flight runs to be restored before the process actually exits.
4//!
5//! Multiple tune tasks are allowed. A short-lived exclusive reservation is still used for
6//! post-hoc PID writes/reverts, because those operations mutate a live loop directly and must
7//! not overlap a tune or another write/revert.
8
9use std::collections::BTreeMap;
10use std::sync::Arc;
11use std::time::Duration;
12
13use bhtune_cli::cancel::CtrlCHandle;
14use tokio::sync::Mutex;
15use tokio::task::JoinHandle;
16
17struct ActiveTask {
18    cancel: CtrlCHandle,
19    handle: JoinHandle<()>,
20}
21
22#[derive(Default)]
23struct ActiveRunState {
24    tasks: BTreeMap<i64, ActiveTask>,
25    exclusive: Option<i64>,
26}
27
28/// Cheap to clone (an `Arc<Mutex<..>>` underneath), matching [`crate::state::AppState`]'s
29/// own `Clone` requirement for axum's `State` extractor.
30#[derive(Clone, Default)]
31pub struct ActiveRun {
32    inner: Arc<Mutex<ActiveRunState>>,
33}
34
35/// Returned by [`ActiveRun::start`] or [`ActiveRun::reserve`] when an exclusive write/revert
36/// reservation is already held.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct RunAlreadyActive {
39    pub run_id: i64,
40}
41
42impl ActiveRun {
43    /// Registers `task` as a background tune, recording `cancel` so a later
44    /// [`ActiveRun::cancel`] or [`ActiveRun::cancel_and_wait`] call can trigger it.
45    /// The registration is released automatically after the task returns or panics. If
46    /// registration is rejected, `task` is dropped without being spawned, releasing anything
47    /// it captured (such as Demo concurrency permits).
48    ///
49    /// Multiple tune tasks may be registered at the same time. The only conflict is with an
50    /// exclusive post-hoc write/revert reservation, which protects direct live-loop mutation.
51    pub async fn start(
52        &self,
53        run_id: i64,
54        cancel: CtrlCHandle,
55        task: impl Future<Output = ()> + Send + 'static,
56    ) -> Result<(), RunAlreadyActive> {
57        let mut guard = self.inner.lock().await;
58        if let Some(existing) = guard.exclusive {
59            return Err(RunAlreadyActive { run_id: existing });
60        }
61        if guard.tasks.contains_key(&run_id) {
62            return Err(RunAlreadyActive { run_id });
63        }
64        let task_handle = tokio::spawn(task);
65        let active = self.clone();
66        let handle = tokio::spawn(async move {
67            if let Err(error) = task_handle.await {
68                tracing::error!(run_id, %error, "tune task exited unexpectedly");
69            }
70            active.release(run_id).await;
71        });
72        guard.tasks.insert(run_id, ActiveTask { cancel, handle });
73        Ok(())
74    }
75
76    /// Reserves the registry for a short operation the caller awaits directly -- currently
77    /// only a post-hoc PID write or revert (`api-post-run-write`). Unlike
78    /// [`ActiveRun::start`], nothing is spawned here: the caller must still call
79    /// [`ActiveRun::release`] itself once its own operation finishes, on every exit path
80    /// including an error return.
81    ///
82    /// The reservation is accepted only when all tune tasks and any other reservation have
83    /// finished. This keeps direct live-loop mutation serialized without preventing independent
84    /// tune tasks from running concurrently.
85    pub async fn reserve(&self, run_id: i64) -> Result<(), RunAlreadyActive> {
86        let mut guard = self.inner.lock().await;
87        if let Some(existing) = guard.exclusive {
88            return Err(RunAlreadyActive { run_id: existing });
89        }
90        if let Some((&existing, _)) = guard.tasks.first_key_value() {
91            return Err(RunAlreadyActive { run_id: existing });
92        }
93        guard.exclusive = Some(run_id);
94        Ok(())
95    }
96
97    /// Returns the run id associated with the current exclusive write/revert reservation,
98    /// if any. This is a cheap, non-authoritative pre-check; [`ActiveRun::start`] still
99    /// re-checks under the same mutex to close the race with a reservation beginning between
100    /// this call and the actual task registration.
101    pub async fn exclusive_id(&self) -> Option<i64> {
102        self.inner.lock().await.exclusive
103    }
104
105    /// Returns the ids of all registered background tune tasks. Cancellation always targets
106    /// one id at a time.
107    pub async fn active_run_ids(&self) -> Vec<i64> {
108        self.inner.lock().await.tasks.keys().copied().collect()
109    }
110
111    /// Triggers cancellation for `run_id` if that tune is registered, exactly as if Ctrl+C
112    /// had been pressed against an equivalent CLI-driven run. Returns whether a matching tune
113    /// or exclusive reservation was found.
114    pub async fn cancel(&self, run_id: i64) -> bool {
115        let guard = self.inner.lock().await;
116        if let Some(task) = guard.tasks.get(&run_id) {
117            task.cancel.trigger();
118            true
119        } else {
120            guard.exclusive == Some(run_id)
121        }
122    }
123
124    /// Releases the tune registration or exclusive reservation for `run_id`. Spawned tune
125    /// registrations release themselves; direct callers use this for exclusive reservations.
126    /// A no-op if the id is not registered, which is expected when graceful shutdown has
127    /// already taken the registry's entries for cancellation and waiting.
128    pub async fn release(&self, run_id: i64) {
129        let mut guard = self.inner.lock().await;
130        if guard.exclusive == Some(run_id) {
131            guard.exclusive = None;
132        } else {
133            guard.tasks.remove(&run_id);
134        }
135    }
136
137    /// Triggers cancellation for every registered tune and waits up to `wait_timeout` for
138    /// their background tasks to actually finish, including restore attempts. If the timeout
139    /// elapses, the remaining tasks are abandoned and a loud error names all affected runs.
140    pub async fn cancel_and_wait(&self, wait_timeout: Duration) {
141        let (tasks, _exclusive) = {
142            let mut guard = self.inner.lock().await;
143            let tasks = std::mem::take(&mut guard.tasks);
144            let exclusive = guard.exclusive.take();
145            (tasks, exclusive)
146        };
147        if tasks.is_empty() {
148            return;
149        }
150        let run_ids: Vec<i64> = tasks.keys().copied().collect();
151        let tasks: Vec<_> = tasks
152            .into_iter()
153            .map(|(run_id, task)| {
154                task.cancel.trigger();
155                (run_id, task.cancel, task.handle)
156            })
157            .collect();
158        let wait = async move {
159            for (run_id, _cancel, handle) in tasks {
160                if let Err(error) = handle.await {
161                    tracing::error!(run_id, %error, "tune task exited unexpectedly");
162                }
163            }
164        };
165        if tokio::time::timeout(wait_timeout, wait).await.is_err() {
166            tracing::error!(
167                ?run_ids,
168                ?wait_timeout,
169                "tune tasks did not finish restoring within the shutdown grace period and were \
170                 abandoned; affected loops may have been left mid-test -- check them by hand, \
171                 or run `bhtune history revert` for the affected runs once this process has \
172                 exited"
173            );
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::future::Future;
182    use std::pin::Pin;
183    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
184    use std::task::{Context, Poll};
185    use std::time::Duration;
186    use tokio::sync::oneshot;
187
188    async fn wait_until_inactive(active: &ActiveRun, run_id: i64) {
189        tokio::time::timeout(Duration::from_secs(1), async {
190            while active.active_run_ids().await.contains(&run_id) {
191                tokio::task::yield_now().await;
192            }
193        })
194        .await
195        .expect("the finished task should release its active-run registration");
196    }
197
198    #[tokio::test]
199    async fn start_registers_runs_and_releases_a_completed_task() {
200        let active = ActiveRun::default();
201        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
202        let ran = Arc::new(AtomicBool::new(false));
203        let ran_clone = ran.clone();
204        let (finish_tx, finish_rx) = oneshot::channel();
205        active
206            .start(1, handle, async move {
207                ran_clone.store(true, Ordering::SeqCst);
208                finish_rx.await.unwrap();
209            })
210            .await
211            .unwrap();
212        assert_eq!(active.active_run_ids().await, vec![1]);
213        finish_tx.send(()).unwrap();
214        wait_until_inactive(&active, 1).await;
215        assert!(ran.load(Ordering::SeqCst));
216        assert!(active.reserve(2).await.is_ok());
217        active.release(2).await;
218    }
219
220    #[tokio::test]
221    async fn start_allows_multiple_runs_while_they_are_active() {
222        let active = ActiveRun::default();
223        let (_ctrl_c_1, handle_1) = bhtune_cli::cancel::CtrlC::manual();
224        active
225            .start(1, handle_1, std::future::pending())
226            .await
227            .unwrap();
228
229        let (_ctrl_c_2, handle_2) = bhtune_cli::cancel::CtrlC::manual();
230        active
231            .start(2, handle_2, std::future::pending())
232            .await
233            .unwrap();
234        assert_eq!(active.active_run_ids().await, vec![1, 2]);
235    }
236
237    #[tokio::test]
238    async fn release_frees_the_slot_for_the_matching_run_id() {
239        let active = ActiveRun::default();
240        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
241        active
242            .start(1, handle, std::future::pending())
243            .await
244            .unwrap();
245        active.release(1).await;
246        assert!(active.active_run_ids().await.is_empty());
247    }
248
249    #[tokio::test]
250    async fn releasing_one_run_keeps_other_runs_registered() {
251        let active = ActiveRun::default();
252        let (_ctrl_c_1, handle_1) = bhtune_cli::cancel::CtrlC::manual();
253        let (_ctrl_c_2, handle_2) = bhtune_cli::cancel::CtrlC::manual();
254        active
255            .start(1, handle_1, std::future::pending())
256            .await
257            .unwrap();
258        active
259            .start(2, handle_2, std::future::pending())
260            .await
261            .unwrap();
262
263        active.release(1).await;
264
265        assert_eq!(active.active_run_ids().await, vec![2]);
266    }
267
268    #[tokio::test]
269    async fn release_is_a_no_op_for_a_non_matching_run_id() {
270        let active = ActiveRun::default();
271        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
272        active
273            .start(1, handle, std::future::pending())
274            .await
275            .unwrap();
276        active.release(999).await;
277        assert_eq!(active.active_run_ids().await, vec![1]);
278    }
279
280    #[tokio::test]
281    async fn reserve_registers_an_exclusive_operation_without_spawning_anything() {
282        let active = ActiveRun::default();
283        active.reserve(1).await.unwrap();
284        assert_eq!(active.exclusive_id().await, Some(1));
285    }
286
287    #[tokio::test]
288    async fn reserve_refuses_while_a_spawned_task_is_active() {
289        let active = ActiveRun::default();
290        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
291        active
292            .start(1, handle, std::future::pending())
293            .await
294            .unwrap();
295        let err = active.reserve(2).await.unwrap_err();
296        assert_eq!(err, RunAlreadyActive { run_id: 1 });
297    }
298
299    #[tokio::test]
300    async fn start_refuses_while_a_reservation_is_active() {
301        let active = ActiveRun::default();
302        active.reserve(1).await.unwrap();
303        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
304        let err = active
305            .start(2, handle, std::future::pending())
306            .await
307            .unwrap_err();
308        assert_eq!(err, RunAlreadyActive { run_id: 1 });
309    }
310
311    #[tokio::test]
312    async fn rejected_start_drops_the_unscheduled_task_and_keeps_the_reservation() {
313        struct DropProbe(Arc<AtomicBool>);
314
315        impl Drop for DropProbe {
316            fn drop(&mut self) {
317                self.0.store(true, Ordering::SeqCst);
318            }
319        }
320
321        let active = ActiveRun::default();
322        active.reserve(1).await.unwrap();
323        let dropped = Arc::new(AtomicBool::new(false));
324        let probe = DropProbe(dropped.clone());
325        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
326        let mut task = std::future::poll_fn(move |_| {
327            let _ = &probe;
328            Poll::Pending
329        });
330        let waker = std::task::Waker::noop();
331        let mut context = Context::from_waker(waker);
332        assert!(Pin::new(&mut task).poll(&mut context).is_pending());
333
334        let err = active.start(2, handle, task).await.unwrap_err();
335
336        assert_eq!(err, RunAlreadyActive { run_id: 1 });
337        assert!(dropped.load(Ordering::SeqCst));
338        assert_eq!(active.exclusive_id().await, Some(1));
339        assert!(active.active_run_ids().await.is_empty());
340    }
341
342    #[tokio::test]
343    async fn cancel_and_wait_logs_an_unexpected_registration_task_failure() {
344        let active = ActiveRun::default();
345        let (_ctrl_c, cancel) = bhtune_cli::cancel::CtrlC::manual();
346        let handle = tokio::spawn(async {
347            panic!("simulated registration task failure");
348        });
349        active
350            .inner
351            .lock()
352            .await
353            .tasks
354            .insert(1, ActiveTask { cancel, handle });
355
356        active.cancel_and_wait(Duration::from_secs(1)).await;
357
358        assert!(active.active_run_ids().await.is_empty());
359    }
360
361    #[tokio::test]
362    async fn start_refuses_a_duplicate_run_id_while_the_task_is_active() {
363        let active = ActiveRun::default();
364        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
365        active
366            .start(1, handle, std::future::pending())
367            .await
368            .unwrap();
369
370        let (_ctrl_c_duplicate, duplicate_handle) = bhtune_cli::cancel::CtrlC::manual();
371        let err = active
372            .start(1, duplicate_handle, std::future::pending())
373            .await
374            .unwrap_err();
375
376        assert_eq!(err, RunAlreadyActive { run_id: 1 });
377    }
378
379    #[tokio::test]
380    async fn reserve_refuses_a_second_reservation_while_one_is_active() {
381        let active = ActiveRun::default();
382        active.reserve(1).await.unwrap();
383        let err = active.reserve(2).await.unwrap_err();
384        assert_eq!(err, RunAlreadyActive { run_id: 1 });
385    }
386
387    #[tokio::test]
388    async fn release_frees_a_reserved_slot() {
389        let active = ActiveRun::default();
390        active.reserve(1).await.unwrap();
391        active.release(1).await;
392        assert_eq!(active.exclusive_id().await, None);
393    }
394
395    #[tokio::test]
396    async fn cancel_returns_true_for_a_reservation_but_has_nothing_to_trigger() {
397        // A reservation has no `CtrlCHandle` at all, so this only proves `cancel` still
398        // reports "yes, something is active for this id" -- matching a real tune task's
399        // `cancel` return value -- rather than panicking or misreporting `false` just
400        // because there's no cancellation machinery to fire.
401        let active = ActiveRun::default();
402        active.reserve(1).await.unwrap();
403        assert!(active.cancel(1).await);
404        // The reservation itself is untouched by a `cancel` call -- it isn't released.
405        assert_eq!(active.exclusive_id().await, Some(1));
406    }
407
408    #[tokio::test]
409    async fn cancel_and_wait_clears_a_reservation_immediately_without_waiting() {
410        let active = ActiveRun::default();
411        active.reserve(1).await.unwrap();
412        // Must return promptly -- there is no `JoinHandle` to wait for, unlike a spawned
413        // tune task.
414        tokio::time::timeout(
415            Duration::from_millis(200),
416            active.cancel_and_wait(Duration::from_secs(5)),
417        )
418        .await
419        .expect("cancel_and_wait must not block on a reservation with no task to await");
420        assert_eq!(active.exclusive_id().await, None);
421    }
422
423    #[tokio::test]
424    async fn cancel_returns_true_for_the_matching_run_id() {
425        // `CtrlC::signalled` (the observing half) is deliberately `pub(crate)` to
426        // `bhtune-cli` -- see its doc comment -- so this module's own tests can't directly
427        // observe that `trigger()` fired the paired `CtrlC`. That propagation is already
428        // covered by `bhtune-cli::cancel`'s own test suite; what belongs here is only
429        // `ActiveRun::cancel`'s own dispatch logic (right id -> `true`), and the full
430        // end-to-end proof that cancellation actually reaches and stops a running tune
431        // lives in `routes::runs`'s route-level tests.
432        let active = ActiveRun::default();
433        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
434        active
435            .start(1, handle, std::future::pending())
436            .await
437            .unwrap();
438        assert!(active.cancel(1).await);
439    }
440
441    #[tokio::test]
442    async fn cancel_returns_false_for_a_non_matching_run_id() {
443        let active = ActiveRun::default();
444        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
445        active
446            .start(1, handle, std::future::pending())
447            .await
448            .unwrap();
449        assert!(!active.cancel(999).await);
450    }
451
452    #[tokio::test]
453    async fn cancel_returns_false_when_nothing_is_active() {
454        let active = ActiveRun::default();
455        assert!(!active.cancel(1).await);
456    }
457
458    #[tokio::test]
459    async fn cancel_and_wait_is_a_no_op_when_nothing_is_active() {
460        let active = ActiveRun::default();
461        // Must return promptly -- there is nothing to wait for.
462        tokio::time::timeout(
463            Duration::from_millis(200),
464            active.cancel_and_wait(Duration::from_secs(5)),
465        )
466        .await
467        .expect("cancel_and_wait must not block when no run is active");
468    }
469
470    #[tokio::test]
471    async fn cancel_and_wait_waits_for_the_spawned_task_to_actually_finish() {
472        let active = ActiveRun::default();
473        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
474        let finished = Arc::new(AtomicBool::new(false));
475        let finished_clone = finished.clone();
476        // A task that takes a little while to actually stop (simulating an in-flight
477        // restore completing) rather than resolving the instant it's spawned -- proves
478        // `cancel_and_wait` really awaits the `JoinHandle` to completion, rather than
479        // returning as soon as `trigger()` is called. (Whether `trigger()` itself reaches a
480        // real `CtrlC`'s `signalled()` is already covered by `bhtune-cli::cancel`'s own
481        // tests; `CtrlC::signalled` is deliberately `pub(crate)` there and unobservable from
482        // this crate -- see its doc comment.)
483        active
484            .start(1, handle, async move {
485                tokio::time::sleep(Duration::from_millis(50)).await;
486                finished_clone.store(true, Ordering::SeqCst);
487            })
488            .await
489            .unwrap();
490
491        tokio::time::timeout(
492            Duration::from_millis(500),
493            active.cancel_and_wait(Duration::from_secs(5)),
494        )
495        .await
496        .expect("cancel_and_wait should resolve once the task finishes");
497        assert!(
498            finished.load(Ordering::SeqCst),
499            "cancel_and_wait must not return before the task's future has fully resolved"
500        );
501        // cancel_and_wait itself clears the registry (it takes the entries up front), not the
502        // tasks -- confirms no entry leaks even though this test's task never calls `release()`.
503        assert!(active.active_run_ids().await.is_empty());
504    }
505
506    #[tokio::test]
507    async fn cancel_and_wait_waits_for_all_spawned_tasks() {
508        let active = ActiveRun::default();
509        let finished = Arc::new(AtomicUsize::new(0));
510
511        for run_id in [1, 2] {
512            let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
513            let finished_clone = finished.clone();
514            active
515                .start(run_id, handle, async move {
516                    tokio::time::sleep(Duration::from_millis(50)).await;
517                    finished_clone.fetch_add(1, Ordering::SeqCst);
518                })
519                .await
520                .unwrap();
521        }
522
523        tokio::time::timeout(
524            Duration::from_millis(500),
525            active.cancel_and_wait(Duration::from_secs(5)),
526        )
527        .await
528        .expect("cancel_and_wait should resolve after all tasks finish");
529        assert_eq!(finished.load(Ordering::SeqCst), 2);
530        assert!(active.active_run_ids().await.is_empty());
531    }
532
533    #[tokio::test]
534    async fn cancel_and_wait_abandons_a_task_that_does_not_finish_within_the_timeout() {
535        let active = ActiveRun::default();
536        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
537        // A task that ignores cancellation entirely (simulating a stuck restore) -- proves
538        // `cancel_and_wait` still returns rather than blocking forever.
539        active
540            .start(1, handle, std::future::pending())
541            .await
542            .unwrap();
543
544        tokio::time::timeout(
545            Duration::from_millis(500),
546            active.cancel_and_wait(Duration::from_millis(50)),
547        )
548        .await
549        .expect("cancel_and_wait must respect its own timeout even if the task never exits");
550    }
551
552    #[tokio::test]
553    async fn cancel_and_wait_logs_and_consumes_a_panicking_task() {
554        let active = ActiveRun::default();
555        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
556        active
557            .start(1, handle, async {
558                panic!("simulated task failure");
559            })
560            .await
561            .unwrap();
562
563        active.cancel_and_wait(Duration::from_secs(1)).await;
564        assert!(active.active_run_ids().await.is_empty());
565    }
566
567    #[tokio::test]
568    async fn a_panicking_task_releases_its_registration_without_shutdown() {
569        let active = ActiveRun::default();
570        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
571        active
572            .start(1, handle, async {
573                panic!("simulated task failure");
574            })
575            .await
576            .unwrap();
577
578        wait_until_inactive(&active, 1).await;
579        assert!(active.reserve(2).await.is_ok());
580        active.release(2).await;
581    }
582
583    #[tokio::test]
584    async fn a_task_that_reaches_its_own_timeout_releases_its_registration() {
585        let active = ActiveRun::default();
586        let (_ctrl_c, handle) = bhtune_cli::cancel::CtrlC::manual();
587        active
588            .start(1, handle, async {
589                assert!(
590                    tokio::time::timeout(Duration::from_millis(10), std::future::pending::<()>(),)
591                        .await
592                        .is_err()
593                );
594            })
595            .await
596            .unwrap();
597
598        wait_until_inactive(&active, 1).await;
599        assert!(active.reserve(2).await.is_ok());
600        active.release(2).await;
601    }
602}