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_runtime::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. 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_runtime::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_runtime::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_runtime::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_runtime::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_runtime::cancel::CtrlC::manual();
253        let (_ctrl_c_2, handle_2) = bhtune_runtime::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_runtime::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_runtime::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_runtime::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_runtime::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_runtime::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_runtime::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_runtime::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 runtime-private, so this module's tests
426        // can't directly observe that `trigger()` fired the paired `CtrlC`. That propagation
427        // is covered by `bhtune-runtime::cancel`'s test suite; what belongs here is only
428        // `ActiveRun::cancel`'s own dispatch logic (right id -> `true`), and the full
429        // end-to-end proof that cancellation actually reaches and stops a running tune
430        // lives in `routes::runs`'s route-level tests.
431        let active = ActiveRun::default();
432        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
433        active
434            .start(1, handle, std::future::pending())
435            .await
436            .unwrap();
437        assert!(active.cancel(1).await);
438    }
439
440    #[tokio::test]
441    async fn cancel_returns_false_for_a_non_matching_run_id() {
442        let active = ActiveRun::default();
443        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
444        active
445            .start(1, handle, std::future::pending())
446            .await
447            .unwrap();
448        assert!(!active.cancel(999).await);
449    }
450
451    #[tokio::test]
452    async fn cancel_returns_false_when_nothing_is_active() {
453        let active = ActiveRun::default();
454        assert!(!active.cancel(1).await);
455    }
456
457    #[tokio::test]
458    async fn cancel_and_wait_is_a_no_op_when_nothing_is_active() {
459        let active = ActiveRun::default();
460        // Must return promptly -- there is nothing to wait for.
461        tokio::time::timeout(
462            Duration::from_millis(200),
463            active.cancel_and_wait(Duration::from_secs(5)),
464        )
465        .await
466        .expect("cancel_and_wait must not block when no run is active");
467    }
468
469    #[tokio::test]
470    async fn cancel_and_wait_waits_for_the_spawned_task_to_actually_finish() {
471        let active = ActiveRun::default();
472        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
473        let finished = Arc::new(AtomicBool::new(false));
474        let finished_clone = finished.clone();
475        // A task that takes a little while to actually stop (simulating an in-flight
476        // restore completing) rather than resolving the instant it's spawned -- proves
477        // `cancel_and_wait` really awaits the `JoinHandle` to completion, rather than
478        // returning as soon as `trigger()` is called. (Whether `trigger()` itself reaches a
479        // real `CtrlC`'s `signalled()` is already covered by `bhtune-runtime::cancel`'s own
480        // tests; `CtrlC::signalled` is deliberately `pub(crate)` there and unobservable from
481        // this crate -- see its doc comment.)
482        active
483            .start(1, handle, async move {
484                tokio::time::sleep(Duration::from_millis(50)).await;
485                finished_clone.store(true, Ordering::SeqCst);
486            })
487            .await
488            .unwrap();
489
490        tokio::time::timeout(
491            Duration::from_millis(500),
492            active.cancel_and_wait(Duration::from_secs(5)),
493        )
494        .await
495        .expect("cancel_and_wait should resolve once the task finishes");
496        assert!(
497            finished.load(Ordering::SeqCst),
498            "cancel_and_wait must not return before the task's future has fully resolved"
499        );
500        // cancel_and_wait itself clears the registry (it takes the entries up front), not the
501        // tasks -- confirms no entry leaks even though this test's task never calls `release()`.
502        assert!(active.active_run_ids().await.is_empty());
503    }
504
505    #[tokio::test]
506    async fn cancel_and_wait_waits_for_all_spawned_tasks() {
507        let active = ActiveRun::default();
508        let finished = Arc::new(AtomicUsize::new(0));
509
510        for run_id in [1, 2] {
511            let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
512            let finished_clone = finished.clone();
513            active
514                .start(run_id, handle, async move {
515                    tokio::time::sleep(Duration::from_millis(50)).await;
516                    finished_clone.fetch_add(1, Ordering::SeqCst);
517                })
518                .await
519                .unwrap();
520        }
521
522        tokio::time::timeout(
523            Duration::from_millis(500),
524            active.cancel_and_wait(Duration::from_secs(5)),
525        )
526        .await
527        .expect("cancel_and_wait should resolve after all tasks finish");
528        assert_eq!(finished.load(Ordering::SeqCst), 2);
529        assert!(active.active_run_ids().await.is_empty());
530    }
531
532    #[tokio::test]
533    async fn cancel_and_wait_abandons_a_task_that_does_not_finish_within_the_timeout() {
534        let active = ActiveRun::default();
535        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
536        // A task that ignores cancellation entirely (simulating a stuck restore) -- proves
537        // `cancel_and_wait` still returns rather than blocking forever.
538        active
539            .start(1, handle, std::future::pending())
540            .await
541            .unwrap();
542
543        tokio::time::timeout(
544            Duration::from_millis(500),
545            active.cancel_and_wait(Duration::from_millis(50)),
546        )
547        .await
548        .expect("cancel_and_wait must respect its own timeout even if the task never exits");
549    }
550
551    #[tokio::test]
552    async fn cancel_and_wait_logs_and_consumes_a_panicking_task() {
553        let active = ActiveRun::default();
554        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
555        active
556            .start(1, handle, async {
557                panic!("simulated task failure");
558            })
559            .await
560            .unwrap();
561
562        active.cancel_and_wait(Duration::from_secs(1)).await;
563        assert!(active.active_run_ids().await.is_empty());
564    }
565
566    #[tokio::test]
567    async fn a_panicking_task_releases_its_registration_without_shutdown() {
568        let active = ActiveRun::default();
569        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
570        active
571            .start(1, handle, async {
572                panic!("simulated task failure");
573            })
574            .await
575            .unwrap();
576
577        wait_until_inactive(&active, 1).await;
578        assert!(active.reserve(2).await.is_ok());
579        active.release(2).await;
580    }
581
582    #[tokio::test]
583    async fn a_task_that_reaches_its_own_timeout_releases_its_registration() {
584        let active = ActiveRun::default();
585        let (_ctrl_c, handle) = bhtune_runtime::cancel::CtrlC::manual();
586        active
587            .start(1, handle, async {
588                assert!(
589                    tokio::time::timeout(Duration::from_millis(10), std::future::pending::<()>(),)
590                        .await
591                        .is_err()
592                );
593            })
594            .await
595            .unwrap();
596
597        wait_until_inactive(&active, 1).await;
598        assert!(active.reserve(2).await.is_ok());
599        active.release(2).await;
600    }
601}