Skip to main content

fast_pull/core/
mod.rs

1//! Top-level download orchestration: session handle plus single- and
2//! multi-threaded entry points.
3//!
4//! [`download_single`](crate::single::download_single) runs a sequential pull,
5//! while [`download_multi`](crate::multi::download_multi) splits the work across
6//! concurrent workers with work-stealing. Both return a [`DownloadResult`], a
7//! cheaply cloneable handle that keeps the session alive until the last clone is
8//! dropped (or [`DownloadResult::abort`] is called).
9
10use crate::Event;
11use crossfire::{MAsyncRx, mpmc};
12use fast_steal::{Executor, TaskQueue};
13use std::fmt;
14use std::sync::Arc;
15use tokio_util::sync::CancellationToken;
16
17pub mod mock;
18pub mod multi;
19pub mod single;
20
21/// Shared state of an active download session.
22///
23/// Owned inside an `Arc` by [`DownloadResult`]. Because all clones of a
24/// `DownloadResult` share the **same** `Arc<DownloadResultInner>`, the `Drop`
25/// impl below runs exactly once — when the last clone is dropped. That is where
26/// cancellation happens, giving `DownloadResult` `Arc`-style "last owner gone →
27/// release" semantics: the download keeps running as long as any handle is
28/// alive, and is cancelled only when the final one is dropped.
29struct DownloadResultInner<E, PullError, PushError>
30where
31    E: Executor + Send + Sync,
32    PullError: Send + Unpin + 'static,
33    PushError: Send + Unpin + 'static,
34{
35    event_chain: MAsyncRx<mpmc::List<Event<PullError, PushError>>>,
36    /// The work-stealing queue of a multi-threaded session together with the
37    /// executor that spawns its workers, or `None` for a single-threaded one.
38    ///
39    /// The executor is kept for the entire lifetime of the handle so
40    /// [`set_threads`](Self::set_threads) can spawn additional workers while the
41    /// download is running. It reaches the session's channels through a weak
42    /// reference, so retaining it here never keeps a finished session open.
43    task_queue: Option<(E, TaskQueue<E::Handle>)>,
44    /// Session-wide cancellation token, shared (as a clone) with every worker
45    /// and with the `spawn_blocking` push driver. [`abort`](Self::abort) cancels
46    /// this root token, which broadcasts to all linked child tokens — including
47    /// workers spawned *after* the cancel call — so a late worker observes
48    /// cancellation on its next poll instead of needing a separate one-shot
49    /// notify per handle. Cancellation is terminal: once cancelled it never
50    /// clears, so `is_aborted` stays `true` for the rest of the session.
51    abort_token: CancellationToken,
52}
53
54impl<E, PullError, PushError> fmt::Debug for DownloadResultInner<E, PullError, PushError>
55where
56    E: Executor + Send + Sync,
57    PullError: Send + Unpin + 'static,
58    PushError: Send + Unpin + 'static,
59{
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.debug_struct("DownloadResultInner")
62            .field("event_chain", &self.event_chain)
63            .field("is_aborted", &self.abort_token.is_cancelled())
64            .finish_non_exhaustive()
65    }
66}
67
68impl<E, PullError, PushError> DownloadResultInner<E, PullError, PushError>
69where
70    E: Executor + Send + Sync,
71    PullError: Send + Unpin + 'static,
72    PushError: Send + Unpin + 'static,
73{
74    /// Cancel all workers immediately.
75    ///
76    /// Safe to call multiple times and safe to call while other clones of the
77    /// owning [`DownloadResult`] are still alive. The implicit drop-based
78    /// cancellation (on the last clone) becomes a no-op once this has run.
79    pub fn abort(&self) {
80        self.abort_token.cancel();
81    }
82
83    pub fn set_threads(&self, threads: usize, min_chunk_size: u64) -> Option<()> {
84        let (executor, task_queue) = self.task_queue.as_ref()?;
85        task_queue.set_threads(threads, min_chunk_size, Some(executor))
86    }
87
88    #[must_use]
89    pub fn is_aborted(&self) -> bool {
90        self.abort_token.is_cancelled()
91    }
92}
93
94impl<E, PullError, PushError> Drop for DownloadResultInner<E, PullError, PushError>
95where
96    E: Executor + Send + Sync,
97    PullError: Send + Unpin + 'static,
98    PushError: Send + Unpin + 'static,
99{
100    fn drop(&mut self) {
101        self.abort();
102    }
103}
104
105/// Handle to an active download session.
106///
107/// Cheaply cloneable shared handle. The underlying download keeps running as
108/// long as **any** clone is alive, and is cancelled only once the last clone is
109/// dropped. An explicit [`abort`](Self::abort) cancels immediately.
110///
111/// `DownloadResult` wraps `Arc<DownloadResultInner>` and exposes the session
112/// methods (`abort`, `set_threads`, `is_aborted`) and
113/// [`event_chain`](Self::event_chain) directly; each delegates to the inner
114/// value. There is intentionally **no** `Deref` impl — `DownloadResultInner`
115/// is private, so callers reach session state only through these methods.
116///
117/// Completion is observed by draining [`event_chain`](Self::event_chain): once
118/// the last sender is dropped (the download finished or was aborted) the
119/// receiver disconnects, so `while result.event_chain().recv().await.is_ok() {}`
120/// awaits the session end.
121pub struct DownloadResult<E, PullError, PushError>
122where
123    E: Executor + Send + Sync,
124    PullError: Send + Unpin + 'static,
125    PushError: Send + Unpin + 'static,
126{
127    inner: Arc<DownloadResultInner<E, PullError, PushError>>,
128}
129
130impl<E, PullError, PushError> fmt::Debug for DownloadResult<E, PullError, PushError>
131where
132    E: Executor + Send + Sync,
133    PullError: Send + Unpin + 'static,
134    PushError: Send + Unpin + 'static,
135{
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("DownloadResult")
138            .field("inner", &self.inner)
139            .finish()
140    }
141}
142
143impl<E, PullError, PushError> Clone for DownloadResult<E, PullError, PushError>
144where
145    E: Executor + Send + Sync,
146    PullError: Send + Unpin + 'static,
147    PushError: Send + Unpin + 'static,
148{
149    fn clone(&self) -> Self {
150        Self {
151            inner: self.inner.clone(),
152        }
153    }
154}
155
156impl<E, PullError, PushError> DownloadResult<E, PullError, PushError>
157where
158    E: Executor + Send + Sync,
159    PullError: Send + Unpin + 'static,
160    PushError: Send + Unpin + 'static,
161{
162    /// Construct a [`DownloadResult`] from the raw session pieces.
163    ///
164    /// This is an internal constructor used by
165    /// [`download_single`](crate::single::download_single) and
166    /// [`download_multi`](crate::multi::download_multi); prefer those entry
167    /// points instead of calling this directly.
168    pub fn new(
169        event_chain: MAsyncRx<mpmc::List<Event<PullError, PushError>>>,
170        task_queue: Option<(E, TaskQueue<E::Handle>)>,
171        abort_token: CancellationToken,
172    ) -> Self {
173        Self {
174            inner: Arc::new(DownloadResultInner {
175                event_chain,
176                task_queue,
177                abort_token,
178            }),
179        }
180    }
181
182    /// Access the stream of [`Event`]s emitted during the session.
183    ///
184    /// The receiver closes once the last clone of this handle is dropped or the
185    /// session is aborted, so draining it is a natural way to observe progress.
186    #[must_use]
187    pub fn event_chain(&self) -> &MAsyncRx<mpmc::List<Event<PullError, PushError>>> {
188        &self.inner.event_chain
189    }
190
191    /// Cancel all workers immediately.
192    ///
193    /// Safe to call multiple times and safe to call while other clones of the
194    /// owning [`DownloadResult`] are still alive. The implicit drop-based
195    /// cancellation (on the last clone) becomes a no-op once this has run.
196    pub fn abort(&self) {
197        self.inner.abort();
198    }
199
200    /// Adjust the worker thread count and minimum chunk size of a running
201    /// multi-threaded session.
202    ///
203    /// Growing spawns workers for ranges still waiting in the queue, or splits a
204    /// range off the busiest running worker when nothing is waiting. Shrinking
205    /// aborts the surplus workers and returns their ranges to the queue for the
206    /// survivors to steal. No-op for single-threaded sessions, which have no
207    /// task queue.
208    ///
209    /// A session ends when its last worker exits, and it cannot be restarted:
210    /// growing afterwards spawns nothing, because the workers' shared channels
211    /// are closed at that point. Shrinking is clamped to a minimum of one worker
212    /// by the scheduler, so `set_threads(0)` keeps a single worker alive rather
213    /// than ending the session. The session terminates only when that last
214    /// (clamped) worker exits on its own.
215    ///
216    /// This never touches the [`abort`](Self::abort) token: abort is terminal and
217    /// cannot be undone by resizing, so [`is_aborted`](Self::is_aborted) stays
218    /// `true` across any later `set_threads` call, and any worker spawned by such
219    /// a call observes the cancelled token and exits without pulling.
220    pub fn set_threads(&self, threads: usize, min_chunk_size: u64) {
221        self.inner.set_threads(threads, min_chunk_size);
222    }
223
224    /// Whether the session has been (or is being) cancelled.
225    #[must_use]
226    pub fn is_aborted(&self) -> bool {
227        self.inner.is_aborted()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    #![allow(clippy::unwrap_used)]
234    #![allow(clippy::cast_possible_truncation)]
235    use crate::MemPusher;
236    use crate::mock::{MockPuller, build_mock_data};
237    use crate::multi::{DownloadOptions, download_multi};
238    use crate::{Event, ProgressEntry, PullResult, PullStream, Puller};
239    use bytes::Bytes;
240    use futures::{StreamExt, stream};
241    use std::collections::BTreeSet;
242    use std::sync::Arc;
243    use tokio::time::{Duration, sleep, timeout};
244
245    /// A [`Puller`] that stalls for `delay` before yielding a range in one piece,
246    /// so a test can resize the worker pool while the download is still running.
247    #[derive(Debug, Clone)]
248    struct SlowPuller {
249        data: Arc<[u8]>,
250        delay: Duration,
251    }
252    impl Puller for SlowPuller {
253        type Error = std::convert::Infallible;
254        fn pull(
255            &mut self,
256            range: Option<&ProgressEntry>,
257        ) -> impl Future<Output = PullResult<impl PullStream<Self::Error>, Self::Error>> + Send
258        {
259            type PullItem = PullResult<Bytes, std::convert::Infallible>;
260            let owned: Vec<u8> = match range {
261                Some(r) => self.data[r.start as usize..r.end as usize].to_vec(),
262                None => self.data.to_vec(),
263            };
264            let delay = self.delay;
265            async move {
266                sleep(delay).await;
267                let items: Vec<PullItem> = vec![Ok(Bytes::from(owned))];
268                Ok(stream::iter(items))
269            }
270        }
271    }
272
273    /// A [`Puller`] that yields its range in small pieces with a pause between
274    /// each, so a shrink that aborts a worker lands *mid-range* instead of on a
275    /// clean boundary. That is the shape which exercises cursor hand-off: the
276    /// reclaimed range must resume from the advanced cursor, without dropping or
277    /// re-delivering the bytes the aborted worker had already pushed.
278    #[derive(Debug, Clone)]
279    struct ChunkedPuller {
280        data: Arc<[u8]>,
281        piece: usize,
282        delay: Duration,
283    }
284    impl Puller for ChunkedPuller {
285        type Error = std::convert::Infallible;
286        fn pull(
287            &mut self,
288            range: Option<&ProgressEntry>,
289        ) -> impl Future<Output = PullResult<impl PullStream<Self::Error>, Self::Error>> + Send
290        {
291            let owned: Vec<u8> = match range {
292                Some(r) => self.data[r.start as usize..r.end as usize].to_vec(),
293                None => self.data.to_vec(),
294            };
295            let piece = self.piece;
296            let delay = self.delay;
297            async move {
298                Ok(
299                    stream::unfold(Bytes::from(owned), move |mut buf| async move {
300                        if buf.is_empty() {
301                            return None;
302                        }
303                        let next = buf.split_to(piece.min(buf.len()));
304                        sleep(delay).await;
305                        Some((Ok(next), buf))
306                    })
307                    .boxed(),
308                )
309            }
310        }
311    }
312
313    /// Deterministic xorshift64. A churn schedule driven by a fixed seed keeps a
314    /// failure reproducible instead of turning the test into a lottery.
315    fn next_rand(state: &mut u64) -> u64 {
316        let mut x = *state;
317        x ^= x << 13;
318        x ^= x >> 7;
319        x ^= x << 17;
320        *state = x;
321        x
322    }
323
324    /// Eight equally sized ranges over `size` bytes, so a one-worker session
325    /// leaves seven of them waiting in the queue.
326    fn eight_chunks(size: u64) -> Vec<ProgressEntry> {
327        let step = size / 8;
328        (0..8).map(|i| i * step..(i + 1) * step).collect()
329    }
330
331    #[tokio::test(flavor = "multi_thread")]
332    async fn download_result_debug_and_set_threads() {
333        let mock_data = build_mock_data(1024);
334        let puller = MockPuller::new(&mock_data);
335        let pusher = MemPusher::with_capacity(mock_data.len());
336        let receive = pusher.receive.clone();
337        #[allow(clippy::single_range_in_vec_init)]
338        let download_chunks = [0..mock_data.len() as u64];
339        let result = download_multi(
340            puller,
341            pusher,
342            DownloadOptions {
343                concurrent: 4,
344                retry_gap: Duration::from_secs(1),
345                push_queue_cap: 1024,
346                download_chunks: download_chunks.iter().cloned(),
347                pull_timeout: Duration::from_secs(5),
348                min_chunk_size: 1,
349                max_speculative: 3,
350            },
351        );
352        // `Debug` of `DownloadResultInner` is reached through `DownloadResult`'s
353        // own `Debug` impl, which forwards to the inner value.
354        let _ = format!("{result:?}");
355        // Lines 234-236 (forwarding) and 111-123 (inner task-queue adjustment).
356        result.set_threads(4, 1);
357        // Await completion by draining `event_chain` (it disconnects once the
358        // push driver drops its sender) — this replaces `join()`.
359        while result.event_chain().recv().await.is_ok() {}
360        assert_eq!(&**receive.lock(), mock_data);
361    }
362
363    #[tokio::test(flavor = "multi_thread")]
364    async fn download_result_clone_and_set_threads_no_queue() {
365        use crate::single::download_single;
366        let mock_data = build_mock_data(1024);
367        let puller = MockPuller::new(&mock_data);
368        let pusher = MemPusher::with_capacity(mock_data.len());
369        let receive = pusher.receive.clone();
370        // Single-threaded sessions have no task queue, so `set_threads` is a no-op
371        // (covers the `if let` else path, line 122 of `DownloadResultInner`).
372        let result = download_single(
373            puller,
374            pusher,
375            crate::single::DownloadOptions {
376                retry_gap: Duration::from_secs(1),
377                push_queue_cap: 1024,
378            },
379        );
380        // Lines 167-171: `DownloadResult` is `Clone`.
381        let _clone = result.clone();
382        result.set_threads(4, 1);
383        while result.event_chain().recv().await.is_ok() {}
384        assert_eq!(&**receive.lock(), mock_data);
385    }
386
387    // Pins the `is_aborted` interaction with `set_threads`: a *live*
388    // (never-aborted) session keeps `is_aborted() == false` after `set_threads`,
389    // while an *already-aborted* session stays aborted — the flag is a one-way
390    // latch, so `is_aborted()` cannot report a false "live" state for a cancelled
391    // download.
392    #[tokio::test(flavor = "multi_thread")]
393    async fn set_threads_does_not_unabort_an_aborted_session() {
394        let mock_data = build_mock_data(1024);
395        let puller = MockPuller::new(&mock_data);
396        let pusher = MemPusher::with_capacity(mock_data.len());
397        let result = download_multi(
398            puller,
399            pusher,
400            DownloadOptions {
401                concurrent: 4,
402                retry_gap: Duration::from_secs(1),
403                push_queue_cap: 1024,
404                download_chunks: std::iter::once(0..mock_data.len() as u64),
405                pull_timeout: Duration::from_secs(5),
406                min_chunk_size: 1,
407                max_speculative: 3,
408            },
409        );
410        // Live session: flag starts false and must stay false after a resize.
411        assert!(!result.is_aborted());
412        result.set_threads(4, 1);
413        assert!(!result.is_aborted());
414        // Now abort: flag is true and must *stay* true across a later resize.
415        result.abort();
416        assert!(result.is_aborted());
417        result.set_threads(4, 1);
418        assert!(result.is_aborted());
419        while result.event_chain().recv().await.is_ok() {}
420    }
421
422    // Growing the pool of a *running* session must actually put more workers to
423    // work. The session starts with a single worker and seven ranges waiting;
424    // if the growth were a no-op, every `Pulling` event would carry worker id 0.
425    #[tokio::test(flavor = "multi_thread")]
426    async fn set_threads_growth_spawns_additional_workers() {
427        let mock_data = build_mock_data(8 * 1024);
428        let download_chunks = eight_chunks(mock_data.len() as u64);
429        let puller = SlowPuller {
430            data: Arc::from(mock_data.as_slice()),
431            delay: Duration::from_millis(150),
432        };
433        let pusher = MemPusher::with_capacity(mock_data.len());
434        let receive = pusher.receive.clone();
435        let result = download_multi(
436            puller,
437            pusher,
438            DownloadOptions {
439                concurrent: 1,
440                retry_gap: Duration::from_secs(1),
441                push_queue_cap: 1024,
442                download_chunks: download_chunks.iter().cloned(),
443                pull_timeout: Duration::from_secs(5),
444                min_chunk_size: 1,
445                max_speculative: 1,
446            },
447        );
448
449        // Resize while the lone worker is still stalled on its first range.
450        let grower = result.clone();
451        tokio::spawn(async move {
452            sleep(Duration::from_millis(50)).await;
453            grower.set_threads(8, 1);
454        });
455
456        let mut pulling_ids = BTreeSet::new();
457        while let Ok(e) = result.event_chain().recv().await {
458            if let Event::Pulling(id) = e {
459                pulling_ids.insert(id);
460            }
461        }
462        assert!(
463            pulling_ids.len() > 1,
464            "growth spawned no additional worker (pulling ids: {pulling_ids:?})"
465        );
466        assert_eq!(&**receive.lock(), mock_data);
467    }
468
469    // The mirror image of the test above: a worker spawned by a growth that
470    // races an `abort` must observe the abort latch and exit without pulling, so
471    // the session still finalizes promptly instead of resuming.
472    #[tokio::test(flavor = "multi_thread")]
473    async fn set_threads_growth_after_abort_does_not_resume() {
474        let mock_data = build_mock_data(8 * 1024);
475        let download_chunks = eight_chunks(mock_data.len() as u64);
476        let puller = SlowPuller {
477            data: Arc::from(mock_data.as_slice()),
478            delay: Duration::from_millis(150),
479        };
480        let pusher = MemPusher::with_capacity(mock_data.len());
481        let receive = pusher.receive.clone();
482        let result = download_multi(
483            puller,
484            pusher,
485            DownloadOptions {
486                concurrent: 1,
487                retry_gap: Duration::from_secs(1),
488                push_queue_cap: 1024,
489                download_chunks: download_chunks.iter().cloned(),
490                pull_timeout: Duration::from_secs(5),
491                min_chunk_size: 1,
492                max_speculative: 1,
493            },
494        );
495
496        result.abort();
497        result.set_threads(8, 1);
498        // Await termination by draining `event_chain` (replaces `join()`); the
499        // timeout guards against a hang.
500        timeout(Duration::from_secs(10), async {
501            while result.event_chain().recv().await.is_ok() {}
502        })
503        .await
504        .expect("join() hung after abort followed by growth");
505        assert!(
506            receive.lock().len() < mock_data.len(),
507            "an aborted session must not be resumed by a later resize"
508        );
509    }
510
511    // Repeatedly resizing a *running* pool in both directions must never corrupt
512    // the download. Each shrink aborts live workers and reclaims their ranges
513    // mid-flight; each growth hands those ranges to fresh workers. A cursor
514    // hand-off that is off by even one piece shows up here as missing or
515    // duplicated bytes, which a single grow-once test cannot catch. The pieces
516    // are small and paced so the churn lands inside a range rather than on a
517    // boundary, and the schedule is seeded so any failure reproduces exactly.
518    #[tokio::test(flavor = "multi_thread")]
519    async fn set_threads_random_churn_preserves_all_bytes() {
520        let mock_data = build_mock_data(64 * 1024);
521        let download_chunks = eight_chunks(mock_data.len() as u64);
522        let puller = ChunkedPuller {
523            data: Arc::from(mock_data.as_slice()),
524            piece: 512,
525            delay: Duration::from_millis(2),
526        };
527        let pusher = MemPusher::with_capacity(mock_data.len());
528        let receive = pusher.receive.clone();
529        let result = download_multi(
530            puller,
531            pusher,
532            DownloadOptions {
533                concurrent: 4,
534                retry_gap: Duration::from_millis(10),
535                push_queue_cap: 1024,
536                download_chunks: download_chunks.iter().cloned(),
537                pull_timeout: Duration::from_secs(5),
538                min_chunk_size: 1,
539                max_speculative: 3,
540            },
541        );
542
543        // Never resize to zero: that collapses the pool and finalizes the
544        // session, which is a separate contract from mid-flight churn.
545        let churner = result.clone();
546        let probe = receive.clone();
547        let total = mock_data.len();
548        let churn = tokio::spawn(async move {
549            let mut state = 0x2545_F491_4F6C_DD1D_u64;
550            let mut seen = BTreeSet::new();
551            // Resizes landing after the last byte is written prove nothing, so
552            // count the ones that actually hit a still-running download.
553            let mut inflight = 0usize;
554            for _ in 0..40 {
555                let threads = (next_rand(&mut state) % 8 + 1) as usize;
556                seen.insert(threads);
557                if probe.lock().len() < total {
558                    inflight += 1;
559                }
560                churner.set_threads(threads, 1);
561                sleep(Duration::from_millis(3)).await;
562            }
563            // Leave a healthy pool behind so the remaining ranges drain.
564            churner.set_threads(8, 1);
565            (seen, inflight)
566        });
567
568        let chunk_count = download_chunks.len();
569        let mut pulling_total = 0usize;
570        while let Ok(e) = result.event_chain().recv().await {
571            if matches!(e, Event::Pulling(_)) {
572                pulling_total += 1;
573            }
574        }
575        let (seen, inflight) = churn.await.unwrap();
576        assert!(
577            seen.len() > 2,
578            "churn never varied the pool size, so nothing was exercised: {seen:?}"
579        );
580        // Without this the test could silently degrade into a no-op: a download
581        // that outran the churn loop would take every resize on a dead session.
582        assert!(
583            inflight > 0,
584            "every resize landed after the download finished, so no running pool was churned"
585        );
586        // Reclaimed ranges are handed out again, so a pool that really churned
587        // pulls far more often than once per chunk.
588        assert!(
589            pulling_total > chunk_count,
590            "ranges were never redistributed ({pulling_total} pulls for {chunk_count} chunks)"
591        );
592        assert_eq!(
593            &**receive.lock(),
594            mock_data,
595            "repeated resizing corrupted the downloaded bytes"
596        );
597    }
598}