Skip to main content

jellytau_lib/download/
stop.rs

1//! Stop signalling for in-flight downloads.
2//!
3//! TRACES: UR-055 | DR-168
4//!
5//! Pausing and cancelling used to be database-only: `pause_download` wrote
6//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
7//! download stack — no token, no flag, no abort — so the streaming task kept
8//! running, kept writing bytes, and on finishing overwrote the row with
9//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
10//! which is precisely the reported "pause does not work".
11//!
12//! This is the missing half: a flag per in-flight download that the worker reads
13//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
14//! leave the `.part` file **intact**, which is what lets a resume pick up from
15//! where it stopped via the existing HTTP Range request.
16//!
17//! Kept as a module-level registry rather than on `DownloadManager` because the
18//! two sides never meet: the command handler holds the manager's lock, while the
19//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
20//! Tauri state. A registry both can reach is the smallest thing that works.
21//!
22//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
23
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex, OnceLock};
27
28use crate::utils::lock::MutexSafe;
29
30/// download id → its stop flag, for downloads currently in flight.
31fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
32    static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
33    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
34}
35
36/// Register `download_id` as in-flight and hand back its stop flag.
37///
38/// Called by the worker as it starts. A previous flag for the same id is
39/// replaced, so a download that is paused and later resumed does not inherit the
40/// set flag from its last run and stop immediately.
41pub fn register(download_id: i64) -> Arc<AtomicBool> {
42    let flag = Arc::new(AtomicBool::new(false));
43    registry().lock_safe().insert(download_id, flag.clone());
44    flag
45}
46
47/// Ask an in-flight download to stop.
48///
49/// Returns whether one was actually in flight — the caller uses this to tell a
50/// running download (which will stop shortly) from a merely queued one (which
51/// the database update alone has already handled).
52pub fn signal(download_id: i64) -> bool {
53    match registry().lock_safe().get(&download_id) {
54        Some(flag) => {
55            flag.store(true, Ordering::SeqCst);
56            true
57        }
58        None => false,
59    }
60}
61
62/// Forget a download's flag. Called when its task finishes, however it ended.
63pub fn clear(download_id: i64) {
64    registry().lock_safe().remove(&download_id);
65}
66
67/// Whether a stop has been requested for `download_id`.
68///
69/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
70/// up, so this exists for the tests that assert the registry's behaviour.
71#[cfg(test)]
72pub fn is_stopping(download_id: i64) -> bool {
73    registry()
74        .lock_safe()
75        .get(&download_id)
76        .map(|f| f.load(Ordering::SeqCst))
77        .unwrap_or(false)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// Ids are per-test so the shared registry cannot leak between them.
85    fn unique_id(seed: i64) -> i64 {
86        900_000 + seed
87    }
88
89    #[test]
90    fn test_a_registered_download_starts_unflagged() {
91        let id = unique_id(1);
92        let flag = register(id);
93        assert!(!flag.load(Ordering::SeqCst));
94        assert!(!is_stopping(id));
95        clear(id);
96    }
97
98    #[test]
99    fn test_signal_sets_the_flag_the_worker_reads() {
100        let id = unique_id(2);
101        let flag = register(id);
102
103        assert!(signal(id), "a registered download reports as in flight");
104        assert!(
105            flag.load(Ordering::SeqCst),
106            "the worker's own handle sees it"
107        );
108        assert!(is_stopping(id));
109
110        clear(id);
111    }
112
113    /// The pump only needs to abort a task that exists; a queued row is handled
114    /// by its database status alone.
115    #[test]
116    fn test_signalling_an_unregistered_download_reports_not_in_flight() {
117        assert!(!signal(unique_id(3)));
118    }
119
120    #[test]
121    fn test_clear_forgets_the_download() {
122        let id = unique_id(4);
123        register(id);
124        signal(id);
125        clear(id);
126
127        assert!(!is_stopping(id));
128        assert!(!signal(id), "a cleared download is no longer in flight");
129    }
130
131    /// The bug this guards: pause sets the flag, and resume re-runs the same
132    /// download id. If registering reused the old flag, the resumed run would see
133    /// a set flag and stop instantly — a download that could never be resumed.
134    #[test]
135    fn test_reregistering_clears_a_previous_stop() {
136        let id = unique_id(5);
137        register(id);
138        signal(id);
139        assert!(is_stopping(id));
140
141        let fresh = register(id);
142        assert!(!fresh.load(Ordering::SeqCst));
143        assert!(
144            !is_stopping(id),
145            "a resumed download must not inherit the pause"
146        );
147
148        clear(id);
149    }
150}