Skip to main content

fast_pull/core/
multi.rs

1//! Multi-threaded concurrent download with work-stealing.
2
3use crate::{DownloadResult, Event, ProgressEntry, Puller, PullerError, Pusher, WorkerId};
4use bytes::Bytes;
5use core::{
6    sync::atomic::{AtomicUsize, Ordering},
7    time::Duration,
8};
9use crossfire::{MAsyncTx, MTx, WeakTx, mpmc, mpsc};
10use fast_steal::{Executor, Handle, Task, TaskQueue};
11use futures::TryStreamExt;
12use std::sync::{Arc, OnceLock};
13use tokio_util::sync::CancellationToken;
14
15/// Options for a multi-threaded concurrent download.
16///
17/// Controls chunk splitting, speculation, pull timeouts, and write queue capacity.
18#[derive(Debug, Clone)]
19pub struct DownloadOptions<I: Iterator<Item = ProgressEntry>> {
20    pub download_chunks: I,
21    pub concurrent: usize,
22    pub retry_gap: Duration,
23    pub pull_timeout: Duration,
24    pub push_queue_cap: usize,
25    pub min_chunk_size: u64,
26    pub max_speculative: usize,
27}
28
29pub fn download_multi<R: Puller, W: Pusher, I: Iterator<Item = ProgressEntry>>(
30    puller: R,
31    mut pusher: W,
32    options: DownloadOptions<I>,
33) -> DownloadResult<TokioExecutor<R, W::Error>, R::Error, W::Error> {
34    let token = CancellationToken::new();
35    let (tx, event_chain) = mpmc::unbounded_async();
36    pusher.set_listener({
37        let tx = tx.clone();
38        Box::new(move |p| {
39            let _ = tx.send(Event::PushProgress(p));
40        })
41    });
42    let (tx_push, rx_push) =
43        mpsc::bounded_async_blocking::<(WorkerId, ProgressEntry, Bytes)>(options.push_queue_cap);
44
45    let push_thread = Arc::new(OnceLock::new());
46    let push_handle = tokio::task::spawn_blocking({
47        let push_thread = push_thread.clone();
48        let token = token.clone();
49        let tx = tx.clone();
50        move || {
51            let _ = push_thread.set(std::thread::current());
52            while let Ok((id, mut spin, mut data)) = rx_push.recv() {
53                loop {
54                    if token.is_cancelled() {
55                        return;
56                    }
57                    let _ = tx.send(Event::Pushing(id, spin.clone()));
58                    let len_before_push = data.len();
59                    match pusher.push(&spin, data) {
60                        Ok(()) => break,
61                        Err((err, bytes)) => {
62                            let _ = tx.send(Event::PushError(id, spin.clone(), err));
63                            let written = len_before_push.saturating_sub(bytes.len());
64                            data = bytes;
65                            spin.start += written as u64;
66                        }
67                    }
68                    std::thread::park_timeout(options.retry_gap);
69                }
70            }
71            loop {
72                if token.is_cancelled() {
73                    return;
74                }
75                let _ = tx.send(Event::Flushing);
76                match pusher.flush() {
77                    Ok(()) => break,
78                    Err(err) => {
79                        let _ = tx.send(Event::FlushError(err));
80                    }
81                }
82                std::thread::park_timeout(options.retry_gap);
83            }
84        }
85    });
86    tokio::spawn({
87        let token = token.clone();
88        async move {
89            tokio::select! {
90                _ = push_handle => {},
91                () = token.cancelled() => {
92                    if let Some(t) = push_thread.get() {
93                        t.unpark();
94                    }
95                }
96            }
97        }
98    });
99
100    let executor: TokioExecutor<R, W::Error> = TokioExecutor {
101        token: token.clone(),
102        tx: tx.downgrade(),
103        tx_push: tx_push.downgrade(),
104        puller,
105        id: AtomicUsize::new(0),
106        retry_gap: options.retry_gap,
107        pull_timeout: options.pull_timeout,
108        min_chunk_size: options.min_chunk_size,
109        max_speculative: options.max_speculative,
110    };
111    let task_queue = TaskQueue::new(options.download_chunks);
112    let _ = task_queue.set_threads(options.concurrent, options.min_chunk_size, Some(&executor));
113
114    DownloadResult::new(event_chain, Some((executor, task_queue)), token)
115}
116
117/// A [`Handle`] implementation whose cancellation is a worker-local
118/// [`CancellationToken`], itself a child of the session's root token.
119#[derive(Debug, Clone)]
120pub struct TokioHandle {
121    id: usize,
122    token: CancellationToken,
123}
124impl Handle for TokioHandle {
125    type Id = usize;
126    fn abort(&mut self) {
127        self.token.cancel();
128    }
129    fn is_self(&self, id: &Self::Id) -> bool {
130        self.id == *id
131    }
132}
133/// A built-in [`Executor`] implementation based on tokio tasks.
134///
135/// Each worker is a `tokio::spawn`-ed task that pulls chunks from the puller,
136/// sends them to the write queue, and steals new work via [`TaskQueue`].
137///
138/// The executor outlives the workers — [`DownloadResult::set_threads`] uses it
139/// to grow the pool mid-session — so it must not own anything that keeps a
140/// finished session alive. It therefore reaches the session's channels through
141/// a [`WeakTx`](crossfire::WeakTx): once every worker is gone the upgrade fails
142/// and no further worker can be spawned.
143pub struct TokioExecutor<R, WE>
144where
145    R: Puller,
146    WE: Send + Unpin + 'static,
147{
148    tx: WeakTx<mpmc::List<Event<R::Error, WE>>>,
149    tx_push: WeakTx<mpsc::Array<(WorkerId, ProgressEntry, Bytes)>>,
150    /// Session-wide cancellation token, shared with the push driver and with
151    /// [`DownloadResult::abort`]. Cancelling it broadcasts to every linked
152    /// worker token (including ones spawned after the cancel call), so a worker
153    /// only has to observe the cancel once to know the session is over.
154    token: CancellationToken,
155    puller: R,
156    retry_gap: Duration,
157    pull_timeout: Duration,
158    id: AtomicUsize,
159    min_chunk_size: u64,
160    max_speculative: usize,
161}
162impl<R, WE> Executor for TokioExecutor<R, WE>
163where
164    R: Puller,
165    WE: Send + Unpin + 'static,
166{
167    type Handle = TokioHandle;
168    #[allow(clippy::too_many_lines)]
169    fn execute(&self, mut task: Task, task_queue: TaskQueue<Self::Handle>) -> Self::Handle {
170        let id = self.id.fetch_add(1, Ordering::SeqCst);
171        let token = self.token.child_token();
172
173        let tx: Option<MTx<_>> = self.tx.upgrade();
174        let tx_push: Option<MAsyncTx<_>> = self.tx_push.upgrade();
175        let (Some(tx), Some(tx_push)) = (tx, tx_push) else {
176            return TokioHandle { id, token };
177        };
178
179        let mut puller = self.puller.clone();
180        let min_chunk_size = self.min_chunk_size;
181        let pull_timeout = self.pull_timeout;
182        let cfg_retry_gap = self.retry_gap;
183        let max_speculative = self.max_speculative;
184        let worker_token = token.clone();
185        tokio::spawn(async move {
186            'task: loop {
187                if worker_token.is_cancelled() {
188                    break 'task;
189                }
190                let mut start = task.start();
191                if start >= task.end() {
192                    if task_queue.steal(&id, &mut task, min_chunk_size, max_speculative) {
193                        continue 'task;
194                    }
195                    break 'task;
196                }
197                let _ = tx.send(Event::Pulling(id));
198                let download_range = start..task.end();
199                let mut stream = loop {
200                    let t = tokio::select! {
201                        () = worker_token.cancelled() => break 'task,
202                        t = puller.pull(Some(&download_range)) => t
203                    };
204                    match t {
205                        Ok(t) => break t,
206                        Err((e, retry_gap)) => {
207                            let _ = tx.send(Event::PullError(id, e));
208                            tokio::select! {
209                                () = worker_token.cancelled() => break 'task,
210                                () = tokio::time::sleep(retry_gap.unwrap_or(cfg_retry_gap)) => {}
211                            };
212                        }
213                    }
214                };
215                loop {
216                    let t = tokio::select! {
217                        () = worker_token.cancelled() => break 'task,
218                        () = tokio::time::sleep(pull_timeout) => {
219                            let _ = tx.send(Event::PullTimeout(id));
220                            drop(stream);
221                            puller = puller.clone();
222                            continue 'task;
223                        },
224                        t = stream.try_next() => t,
225                    };
226                    match t {
227                        Ok(Some(mut chunk)) => {
228                            if chunk.is_empty() {
229                                continue;
230                            }
231                            let len = chunk.len() as u64;
232                            let Ok(span) = task.safe_add_start(start, len) else {
233                                start += len;
234                                continue;
235                            };
236                            if span.end >= task.end() {
237                                task_queue.cancel_task(&task, &id);
238                            }
239                            #[allow(clippy::cast_possible_truncation)]
240                            let slice_span =
241                                (span.start - start) as usize..(span.end - start) as usize;
242                            chunk = chunk.slice(slice_span);
243                            start = span.end;
244                            let _ = tx.send(Event::PullProgress(id, span.clone()));
245                            let _ = tx_push.send((id, span, chunk)).await;
246                            if start >= task.end() {
247                                continue 'task;
248                            }
249                        }
250                        Ok(None) => continue 'task,
251                        Err((e, retry_gap)) => {
252                            let is_irrecoverable = e.is_irrecoverable();
253                            let _ = tx.send(Event::PullError(id, e));
254                            tokio::select! {
255                                () = worker_token.cancelled() => break 'task,
256                                () = tokio::time::sleep(retry_gap.unwrap_or(cfg_retry_gap)) => {}
257                            };
258                            if is_irrecoverable {
259                                continue 'task;
260                            }
261                        }
262                    }
263                }
264            }
265            let _ = tx.send(Event::Finished(id));
266        });
267        TokioHandle { id, token }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    #![allow(clippy::cast_possible_truncation)]
274    use vec::Vec;
275
276    use super::*;
277    use crate::{BufWriterPusher, CacheSeqPusher, PullResult};
278    use crate::{
279        MemPusher, Merge, ProgressEntry,
280        mock::{MockPuller, build_mock_data},
281    };
282    use futures::{StreamExt, stream};
283    use std::{dbg, vec};
284    use tokio::time::{sleep, timeout};
285
286    #[tokio::test(flavor = "multi_thread")]
287    async fn test_concurrent_download() {
288        let mock_data = build_mock_data(3 * 1024);
289        let puller = MockPuller::new(&mock_data);
290        let pusher = MemPusher::with_capacity(mock_data.len());
291        // Keep only the data handle for the final assertion; the whole `pusher`
292        // (whose listener holds a clone of the `event_chain` sender) is moved into
293        // the download, so `event_chain` closes once the push thread finishes,
294        // terminating the single drain loop below.
295        let receive = pusher.receive.clone();
296        #[allow(clippy::single_range_in_vec_init)]
297        let download_chunks = [0..mock_data.len() as u64];
298        let result = download_multi(
299            puller,
300            pusher,
301            DownloadOptions {
302                concurrent: 32,
303                retry_gap: Duration::from_secs(1),
304                push_queue_cap: 1024,
305                download_chunks: download_chunks.iter().cloned(),
306                pull_timeout: Duration::from_secs(5),
307                min_chunk_size: 1,
308                max_speculative: 3,
309            },
310        );
311
312        let mut pull_progress: Vec<ProgressEntry> = Vec::new();
313        let mut push_progress: Vec<ProgressEntry> = Vec::new();
314        let mut pull_ids = [false; 32];
315        while let Ok(e) = result.event_chain().recv().await {
316            match e {
317                Event::PullProgress(id, p) => {
318                    pull_ids[id] = true;
319                    pull_progress.merge_progress(p);
320                }
321                Event::PushProgress(p) => push_progress.merge_progress(p),
322                _ => {}
323            }
324        }
325        dbg!(&pull_progress);
326        dbg!(&push_progress);
327        assert_eq!(pull_progress, download_chunks);
328        assert_eq!(push_progress, download_chunks);
329        assert!(pull_ids.iter().any(|x| *x));
330
331        assert_eq!(&**receive.lock(), mock_data);
332    }
333
334    #[tokio::test(flavor = "multi_thread")]
335    async fn test_concurrent_download_abort_discards() {
336        let mock_data = build_mock_data(3 * 1024);
337        let puller = MockPuller::new(&mock_data);
338        let pusher = MemPusher::with_capacity(mock_data.len());
339        let receive = pusher.receive.clone();
340        #[allow(clippy::single_range_in_vec_init)]
341        let download_chunks = [0..mock_data.len() as u64];
342        let result = download_multi(
343            puller,
344            pusher,
345            DownloadOptions {
346                concurrent: 32,
347                retry_gap: Duration::from_secs(1),
348                push_queue_cap: 1024,
349                download_chunks: download_chunks.iter().cloned(),
350                pull_timeout: Duration::from_secs(5),
351                min_chunk_size: 1,
352                max_speculative: 3,
353            },
354        );
355
356        // Abort immediately. The push driver must observe the shared flag and break
357        // out WITHOUT flushing, letting the event loop end promptly.
358        result.abort();
359        assert!(result.is_aborted());
360
361        tokio::time::timeout(Duration::from_secs(10), drain(&result))
362            .await
363            .expect("event loop hung after abort");
364
365        let written = receive.lock().len();
366        assert!(
367            written <= mock_data.len(),
368            "abort must not write beyond the source"
369        );
370    }
371
372    // -------------------------------------------------------------------------
373    // Strengthened abort coverage.
374    //
375    // `BufWriterPusher` only forwards buffered bytes to its inner sink on
376    // `flush()` (or overflow). This concurrent test wraps `MemPusher` with
377    // `CacheSeqPusher<BufWriterPusher<_>>` — the same layering `CacheFilePusher`
378    // uses in production — so out-of-order chunks are reordered into a
379    // contiguous stream *before* `BufWriterPusher` coalesces them. On abort the
380    // un-flushed buffer (CacheSeqPusher's BTreeMap + BufWriterPusher's BytesMut)
381    // is discarded, so the inner sink sees strictly less than the full source
382    // (the bare `MemPusher` test's `written <= source` would also pass if abort
383    // missed). Layering also removes the old flakiness: without `CacheSeqPusher`
384    // the interleaved concurrent writes were all non-contiguous and every piece
385    // was flushed straight through, so a slow abort could drain the whole source.
386    // -------------------------------------------------------------------------
387
388    /// A [`Puller`] that stalls for `delay` before yielding any data, so a test
389    /// can deterministically abort *mid-flight*.
390    #[derive(Debug, Clone)]
391    struct SlowMockPuller {
392        data: Arc<[u8]>,
393        delay: Duration,
394    }
395    impl Puller for SlowMockPuller {
396        type Error = std::convert::Infallible;
397        #[allow(clippy::cast_possible_truncation)]
398        fn pull(
399            &mut self,
400            range: Option<&ProgressEntry>,
401        ) -> impl Future<
402            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
403        > + Send {
404            type PullItem = PullResult<Bytes, std::convert::Infallible>;
405            let owned: Vec<u8> = match range {
406                Some(r) => self.data[r.start as usize..r.end as usize].to_vec(),
407                None => self.data.to_vec(),
408            };
409            let delay = self.delay;
410            async move {
411                sleep(delay).await;
412                let items: Vec<PullItem> = owned
413                    .chunks(2)
414                    .map(|c| Ok(Bytes::from(c.to_vec())))
415                    .collect();
416                Ok(stream::iter(items))
417            }
418        }
419    }
420
421    #[tokio::test(flavor = "multi_thread")]
422    async fn test_concurrent_download_abort_discards_buffered() {
423        // 64 KiB source; the slow puller stalls so the test can abort mid-flight.
424        let mock_data = build_mock_data(64 * 1024);
425        let puller = SlowMockPuller {
426            data: Arc::from(mock_data.as_slice()),
427            delay: Duration::from_millis(50),
428        };
429        // Restore the production reordering layer (CacheFilePusher = CacheSeqPusher<BufWriterPusher<...>>):
430        // concurrent out-of-order chunks are first reordered into contiguous runs inside CacheSeqPusher's BTreeMap,
431        // then fed contiguously to BufWriterPusher (which only coalesces contiguous writes). On abort, the
432        // un-flushed buffer (CacheSeqPusher's BTreeMap + BufWriterPusher's BytesMut) is discarded as a whole,
433        // so the inner MemPusher receives zero bytes -> written is deterministically == 0.
434        // high_watermark is set to source+1 so CacheSeqPusher never proactively evicts and holds everything.
435        let inner = MemPusher::with_capacity(mock_data.len());
436        let receive = inner.receive.clone();
437        let buf = BufWriterPusher::new(inner, mock_data.len() + 1);
438        let pusher = CacheSeqPusher::new(buf, mock_data.len() + 1, 0);
439        #[allow(clippy::single_range_in_vec_init)]
440        let download_chunks = [0..mock_data.len() as u64];
441        let result = download_multi(
442            puller,
443            pusher,
444            DownloadOptions {
445                concurrent: 32,
446                retry_gap: Duration::from_secs(1),
447                push_queue_cap: 1024,
448                download_chunks: download_chunks.iter().cloned(),
449                pull_timeout: Duration::from_secs(5),
450                min_chunk_size: 1,
451                max_speculative: 3,
452            },
453        );
454
455        // Abort as soon as the push driver starts processing (first `Pushing`).
456        let mut aborted = false;
457        while let Ok(e) = result.event_chain().recv().await {
458            if matches!(e, Event::Pushing(_, _)) {
459                result.abort();
460                assert!(result.is_aborted());
461                aborted = true;
462                break;
463            }
464        }
465        assert!(aborted, "expected a Pushing event before aborting");
466
467        timeout(Duration::from_secs(10), drain(&result))
468            .await
469            .expect("event loop hung after abort");
470
471        // Abort stops the download well before completion, so the inner sink
472        // must hold strictly less than the full source (some out-of-order runs
473        // may have been flushed, but completion is impossible after this abort).
474        let written = receive.lock().len();
475        assert!(
476            written < mock_data.len(),
477            "abort must stop before the full source is written (got {written} of {})",
478            mock_data.len()
479        );
480    }
481
482    // -------------------------------------------------------------------------
483    // Coverage for the push/flush error-retry paths (lines 62-67, 77-81) and the
484    // pull / stream error paths (lines 188-192, 239-248) plus the empty-chunk
485    // skip (line 217) and the `SlowMockPuller` `None` branch (line 402).
486    // -------------------------------------------------------------------------
487
488    use parking_lot::Mutex;
489    use std::sync::Arc;
490    use std::sync::atomic::{AtomicBool, Ordering};
491
492    #[derive(Debug)]
493    struct FatalErr;
494    impl std::fmt::Display for FatalErr {
495        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496            f.write_str("fatal")
497        }
498    }
499    impl std::error::Error for FatalErr {}
500    impl crate::PullerError for FatalErr {
501        fn is_irrecoverable(&self) -> bool {
502            true
503        }
504    }
505
506    #[derive(Debug)]
507    struct RecoverableErr;
508    impl std::fmt::Display for RecoverableErr {
509        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510            f.write_str("recoverable")
511        }
512    }
513    impl std::error::Error for RecoverableErr {}
514    impl crate::PullerError for RecoverableErr {
515        fn is_irrecoverable(&self) -> bool {
516            false
517        }
518    }
519
520    /// In-memory sink that can be told to fail the next `push` (lines 62-67) or
521    /// `flush` (lines 77-81).
522    struct FlakySink {
523        fail_push: Arc<AtomicBool>,
524        fail_flush: Arc<AtomicBool>,
525        receive: Arc<Mutex<Vec<u8>>>,
526        listener: Option<crate::ProgressListener>,
527    }
528    impl FlakySink {
529        fn new() -> Self {
530            Self {
531                fail_push: Arc::new(AtomicBool::new(false)),
532                fail_flush: Arc::new(AtomicBool::new(false)),
533                receive: Arc::new(Mutex::new(Vec::new())),
534                listener: None,
535            }
536        }
537    }
538    impl crate::Pusher for FlakySink {
539        type Error = std::io::Error;
540        fn set_listener(&mut self, cb: crate::ProgressListener) {
541            self.listener = Some(cb);
542        }
543        fn push(
544            &mut self,
545            range: &crate::ProgressEntry,
546            bytes: Bytes,
547        ) -> Result<(), (Self::Error, Bytes)> {
548            if self.fail_push.swap(false, Ordering::SeqCst) {
549                return Err((std::io::Error::other("push"), bytes));
550            }
551            let mut g = self.receive.lock();
552            if range.start as usize == g.len() {
553                g.extend_from_slice(&bytes);
554            } else {
555                if g.len() < range.end as usize {
556                    g.resize(range.end as usize, 0);
557                }
558                g[range.start as usize..range.end as usize].copy_from_slice(&bytes);
559            }
560            drop(g);
561            if let Some(l) = &mut self.listener {
562                l(range.clone());
563            }
564            Ok(())
565        }
566        fn flush(&mut self) -> Result<(), Self::Error> {
567            if self.fail_flush.swap(false, Ordering::SeqCst) {
568                Err(std::io::Error::other("flush"))
569            } else {
570                Ok(())
571            }
572        }
573    }
574
575    #[derive(Debug, Clone)]
576    struct EmptyChunkPuller {
577        data: Arc<[u8]>,
578    }
579    impl crate::Puller for EmptyChunkPuller {
580        type Error = std::convert::Infallible;
581        fn pull(
582            &mut self,
583            range: Option<&crate::ProgressEntry>,
584        ) -> impl Future<
585            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
586        > + Send {
587            let data = match range {
588                Some(r) => &self.data[r.start as usize..r.end as usize],
589                None => &self.data,
590            };
591            let mut items: Vec<crate::PullResult<Bytes, std::convert::Infallible>> =
592                vec![Ok(Bytes::new())];
593            items.extend(data.chunks(2).map(|c| Ok(Bytes::copy_from_slice(c))));
594            std::future::ready(Ok(stream::iter(items)))
595        }
596    }
597
598    #[derive(Debug, Clone)]
599    struct PullErrOncePuller {
600        data: Arc<[u8]>,
601        failed: Arc<AtomicBool>,
602    }
603    impl crate::Puller for PullErrOncePuller {
604        type Error = RecoverableErr;
605        fn pull(
606            &mut self,
607            range: Option<&crate::ProgressEntry>,
608        ) -> impl Future<
609            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
610        > + Send {
611            if !self.failed.swap(true, Ordering::SeqCst) {
612                return std::future::ready(Err((RecoverableErr, Some(Duration::ZERO))));
613            }
614            let data = match range {
615                Some(r) => &self.data[r.start as usize..r.end as usize],
616                None => &self.data,
617            };
618            let items: Vec<crate::PullResult<Bytes, RecoverableErr>> = data
619                .chunks(2)
620                .map(|c| Ok(Bytes::copy_from_slice(c)))
621                .collect();
622            std::future::ready(Ok(stream::iter(items)))
623        }
624    }
625
626    #[derive(Debug, Clone)]
627    struct StreamErrOncePuller {
628        data: Arc<[u8]>,
629        failed: Arc<AtomicBool>,
630    }
631    impl crate::Puller for StreamErrOncePuller {
632        type Error = FatalErr;
633        fn pull(
634            &mut self,
635            range: Option<&crate::ProgressEntry>,
636        ) -> impl Future<
637            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
638        > + Send {
639            if !self.failed.swap(true, Ordering::SeqCst) {
640                let items: Vec<crate::PullResult<Bytes, FatalErr>> =
641                    vec![Err((FatalErr, Some(Duration::ZERO)))];
642                return std::future::ready(Ok(stream::iter(items)));
643            }
644            let data = match range {
645                Some(r) => &self.data[r.start as usize..r.end as usize],
646                None => &self.data,
647            };
648            let items: Vec<crate::PullResult<Bytes, FatalErr>> = data
649                .chunks(2)
650                .map(|c| Ok(Bytes::copy_from_slice(c)))
651                .collect();
652            std::future::ready(Ok(stream::iter(items)))
653        }
654    }
655
656    /// Like [`StreamErrOncePuller`] but yields a *recoverable* stream error first,
657    /// so the `is_irrecoverable == false` fall-through (line 248) is exercised.
658    #[derive(Debug, Clone)]
659    struct RecoverableStreamErrOncePuller {
660        data: Arc<[u8]>,
661        failed: Arc<AtomicBool>,
662    }
663    impl crate::Puller for RecoverableStreamErrOncePuller {
664        type Error = RecoverableErr;
665        fn pull(
666            &mut self,
667            range: Option<&crate::ProgressEntry>,
668        ) -> impl Future<
669            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
670        > + Send {
671            if !self.failed.swap(true, Ordering::SeqCst) {
672                let items: Vec<crate::PullResult<Bytes, RecoverableErr>> =
673                    vec![Err((RecoverableErr, Some(Duration::ZERO)))];
674                return std::future::ready(Ok(stream::iter(items)));
675            }
676            let data = match range {
677                Some(r) => &self.data[r.start as usize..r.end as usize],
678                None => &self.data,
679            };
680            let items: Vec<crate::PullResult<Bytes, RecoverableErr>> = data
681                .chunks(2)
682                .map(|c| Ok(Bytes::copy_from_slice(c)))
683                .collect();
684            std::future::ready(Ok(stream::iter(items)))
685        }
686    }
687
688    async fn drain<R, WE>(result: &DownloadResult<TokioExecutor<R, WE>, R::Error, WE>)
689    where
690        R: crate::Puller,
691        WE: Send + Unpin + 'static,
692    {
693        while result.event_chain().recv().await.is_ok() {}
694    }
695
696    #[tokio::test(flavor = "multi_thread")]
697    async fn test_multi_push_error_retries() {
698        // Lines 62-67: a failing inner push is retried after `park_timeout`.
699        let mock_data = build_mock_data(3 * 1024);
700        let puller = MockPuller::new(&mock_data);
701        let sink = FlakySink::new();
702        sink.fail_push.store(true, Ordering::SeqCst);
703        let receive = sink.receive.clone();
704        #[allow(clippy::single_range_in_vec_init)]
705        let download_chunks = [0..mock_data.len() as u64];
706        let result = download_multi(
707            puller,
708            sink,
709            DownloadOptions {
710                concurrent: 32,
711                retry_gap: Duration::ZERO,
712                push_queue_cap: 1024,
713                download_chunks: download_chunks.iter().cloned(),
714                pull_timeout: Duration::from_secs(5),
715                min_chunk_size: 1,
716                max_speculative: 3,
717            },
718        );
719        drain(&result).await;
720        assert_eq!(&**receive.lock(), mock_data);
721    }
722
723    #[tokio::test(flavor = "multi_thread")]
724    async fn test_multi_flush_error_retries() {
725        // Lines 77-81: a failing inner flush is retried after `park_timeout`.
726        let mock_data = build_mock_data(3 * 1024);
727        let puller = MockPuller::new(&mock_data);
728        let sink = FlakySink::new();
729        sink.fail_flush.store(true, Ordering::SeqCst);
730        let receive = sink.receive.clone();
731        #[allow(clippy::single_range_in_vec_init)]
732        let download_chunks = [0..mock_data.len() as u64];
733        let result = download_multi(
734            puller,
735            sink,
736            DownloadOptions {
737                concurrent: 32,
738                retry_gap: Duration::ZERO,
739                push_queue_cap: 1024,
740                download_chunks: download_chunks.iter().cloned(),
741                pull_timeout: Duration::from_secs(5),
742                min_chunk_size: 1,
743                max_speculative: 3,
744            },
745        );
746        drain(&result).await;
747        assert_eq!(&**receive.lock(), mock_data);
748    }
749
750    #[tokio::test(flavor = "multi_thread")]
751    async fn test_multi_pull_error_retries() {
752        // Lines 188-192: a `pull` error (recoverable) is retried.
753        let mock_data = build_mock_data(3 * 1024);
754        let puller = PullErrOncePuller {
755            data: Arc::from(mock_data.as_slice()),
756            failed: Arc::new(AtomicBool::new(false)),
757        };
758        let pusher = MemPusher::with_capacity(mock_data.len());
759        let receive = pusher.receive.clone();
760        #[allow(clippy::single_range_in_vec_init)]
761        let download_chunks = [0..mock_data.len() as u64];
762        let result = download_multi(
763            puller,
764            pusher,
765            DownloadOptions {
766                concurrent: 32,
767                retry_gap: Duration::ZERO,
768                push_queue_cap: 1024,
769                download_chunks: download_chunks.iter().cloned(),
770                pull_timeout: Duration::from_secs(5),
771                min_chunk_size: 1,
772                max_speculative: 3,
773            },
774        );
775        drain(&result).await;
776        assert_eq!(&**receive.lock(), mock_data);
777    }
778
779    #[tokio::test(flavor = "multi_thread")]
780    async fn test_multi_empty_chunk_is_skipped() {
781        // Line 217: an empty chunk yielded by the stream is skipped without error.
782        let mock_data = build_mock_data(3 * 1024);
783        let puller = EmptyChunkPuller {
784            data: Arc::from(mock_data.as_slice()),
785        };
786        let pusher = MemPusher::with_capacity(mock_data.len());
787        let receive = pusher.receive.clone();
788        #[allow(clippy::single_range_in_vec_init)]
789        let download_chunks = [0..mock_data.len() as u64];
790        let result = download_multi(
791            puller,
792            pusher,
793            DownloadOptions {
794                concurrent: 32,
795                retry_gap: Duration::ZERO,
796                push_queue_cap: 1024,
797                download_chunks: download_chunks.iter().cloned(),
798                pull_timeout: Duration::from_secs(5),
799                min_chunk_size: 1,
800                max_speculative: 3,
801            },
802        );
803        drain(&result).await;
804        assert_eq!(&**receive.lock(), mock_data);
805    }
806
807    #[tokio::test(flavor = "multi_thread")]
808    async fn test_multi_stream_error_irrecoverable_retries() {
809        // Lines 239-248: a stream error whose `is_irrecoverable` is true triggers a
810        // `continue 'task` and a re-pull, which then succeeds.
811        let mock_data = build_mock_data(3 * 1024);
812        let puller = StreamErrOncePuller {
813            data: Arc::from(mock_data.as_slice()),
814            failed: Arc::new(AtomicBool::new(false)),
815        };
816        let pusher = MemPusher::with_capacity(mock_data.len());
817        let receive = pusher.receive.clone();
818        #[allow(clippy::single_range_in_vec_init)]
819        let download_chunks = [0..mock_data.len() as u64];
820        let result = download_multi(
821            puller,
822            pusher,
823            DownloadOptions {
824                concurrent: 32,
825                retry_gap: Duration::ZERO,
826                push_queue_cap: 1024,
827                download_chunks: download_chunks.iter().cloned(),
828                pull_timeout: Duration::from_secs(5),
829                min_chunk_size: 1,
830                max_speculative: 3,
831            },
832        );
833        drain(&result).await;
834        assert_eq!(&**receive.lock(), mock_data);
835    }
836
837    #[tokio::test(flavor = "multi_thread")]
838    async fn test_multi_stream_error_recoverable_retries() {
839        // Lines 239-248: a stream error whose `is_irrecoverable` is false does NOT
840        // `continue 'task`; instead it falls through (line 248) and retries the pull,
841        // which then succeeds.
842        let mock_data = build_mock_data(3 * 1024);
843        let puller = RecoverableStreamErrOncePuller {
844            data: Arc::from(mock_data.as_slice()),
845            failed: Arc::new(AtomicBool::new(false)),
846        };
847        let pusher = MemPusher::with_capacity(mock_data.len());
848        let receive = pusher.receive.clone();
849        #[allow(clippy::single_range_in_vec_init)]
850        let download_chunks = [0..mock_data.len() as u64];
851        let result = download_multi(
852            puller,
853            pusher,
854            DownloadOptions {
855                concurrent: 32,
856                retry_gap: Duration::ZERO,
857                push_queue_cap: 1024,
858                download_chunks: download_chunks.iter().cloned(),
859                pull_timeout: Duration::from_secs(5),
860                min_chunk_size: 1,
861                max_speculative: 3,
862            },
863        );
864        drain(&result).await;
865        assert_eq!(&**receive.lock(), mock_data);
866    }
867
868    #[tokio::test]
869    async fn puller_and_error_coverage() {
870        // Exercise `Display` for the test error types and the `None` arm of each
871        // test puller's `match range` (lines 586, 613, 641).
872        assert_eq!(format!("{FatalErr}"), "fatal");
873        assert_eq!(format!("{RecoverableErr}"), "recoverable");
874
875        let mut empty = EmptyChunkPuller {
876            data: Arc::from(b"abcdef".as_slice()),
877        };
878        let _ = empty.pull(Some(&(0..2u64))).await;
879        let _ = empty.pull(None).await;
880
881        let mut pull_err = PullErrOncePuller {
882            data: Arc::from(b"abcdef".as_slice()),
883            failed: Arc::new(AtomicBool::new(false)),
884        };
885        let _ = pull_err.pull(Some(&(0..2u64))).await; // first call errors, sets `failed`
886        let _ = pull_err.pull(None).await; // success path, `None` arm
887
888        let mut stream_err = StreamErrOncePuller {
889            data: Arc::from(b"abcdef".as_slice()),
890            failed: Arc::new(AtomicBool::new(false)),
891        };
892        let _ = stream_err.pull(Some(&(0..2u64))).await;
893        let _ = stream_err.pull(None).await;
894
895        let mut rec_stream_err = RecoverableStreamErrOncePuller {
896            data: Arc::from(b"abcdef".as_slice()),
897            failed: Arc::new(AtomicBool::new(false)),
898        };
899        let _ = rec_stream_err.pull(Some(&(0..2u64))).await; // first: `Some` arm + error
900        let _ = rec_stream_err.pull(Some(&(0..2u64))).await; // not-first: `Some` arm
901        let _ = rec_stream_err.pull(None).await; // `None` arm
902    }
903
904    #[tokio::test]
905    async fn test_slow_mock_puller_none_range() {
906        // Line 402: the `None` branch of `SlowMockPuller::pull` (only the `Some`
907        // branch is exercised by the concurrent download path).
908        let mut p = SlowMockPuller {
909            data: Arc::from(b"hello world".as_slice()),
910            delay: Duration::ZERO,
911        };
912        assert!(p.pull(None).await.is_ok());
913        let mut p2 = SlowMockPuller {
914            data: Arc::from(b"hello world".as_slice()),
915            delay: Duration::ZERO,
916        };
917        assert!(p2.pull(Some(&(0..5))).await.is_ok());
918    }
919
920    /// A [`Puller`] that yields one chunk on its *first* pull, then a stream that
921    /// never completes — forcing the worker's `pull_timeout` branch (lines 203-210)
922    /// to fire. The *second* pull returns the remaining range, so the download
923    /// recovers by re-pulling instead of hanging.
924    #[derive(Debug, Clone)]
925    struct TimeoutOncePuller {
926        data: Arc<[u8]>,
927        first: Arc<AtomicBool>,
928    }
929    impl crate::Puller for TimeoutOncePuller {
930        type Error = std::convert::Infallible;
931        fn pull(
932            &mut self,
933            range: Option<&crate::ProgressEntry>,
934        ) -> impl Future<
935            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
936        > + Send {
937            let is_first = !self.first.swap(true, Ordering::SeqCst);
938            let data: Vec<u8> = match range {
939                Some(r) => self.data[r.start as usize..r.end as usize].to_vec(),
940                None => self.data.to_vec(),
941            };
942            async move {
943                if is_first {
944                    // Yield one chunk, then a stream that never completes, so the
945                    // worker's `pull_timeout` branch (lines 203-210) fires and the
946                    // worker re-pulls the remaining range.
947                    let head = data.get(..2).unwrap_or(&data);
948                    let items = vec![Ok(Bytes::copy_from_slice(head))];
949                    let pending =
950                        stream::pending::<crate::PullResult<Bytes, std::convert::Infallible>>();
951                    Ok(stream::iter(items).chain(pending))
952                } else {
953                    // Subsequent pulls return the full remaining range; the trailing
954                    // `pending` is never polled because the worker exits the read
955                    // loop on `start >= end` before reaching it.
956                    let items: Vec<crate::PullResult<Bytes, std::convert::Infallible>> = data
957                        .chunks(2)
958                        .map(|c| Ok(Bytes::copy_from_slice(c)))
959                        .collect();
960                    let pending =
961                        stream::pending::<crate::PullResult<Bytes, std::convert::Infallible>>();
962                    Ok(stream::iter(items).chain(pending))
963                }
964            }
965        }
966    }
967
968    #[tokio::test(flavor = "multi_thread")]
969    async fn test_multi_pull_timeout_recovers_by_repulling() {
970        // Lines 203-210: a stalled stream (first pull yields one chunk then hangs)
971        // must trigger `PullTimeout`, drop the stream, and re-pull the remaining
972        // range — the download still completes with the full payload.
973        let mock_data = build_mock_data(3 * 1024);
974        let puller = TimeoutOncePuller {
975            data: Arc::from(mock_data.as_slice()),
976            first: Arc::new(AtomicBool::new(false)),
977        };
978        let pusher = MemPusher::with_capacity(mock_data.len());
979        let receive = pusher.receive.clone();
980        #[allow(clippy::single_range_in_vec_init)]
981        let download_chunks = [0..mock_data.len() as u64];
982        let result = download_multi(
983            puller,
984            pusher,
985            DownloadOptions {
986                concurrent: 32,
987                retry_gap: Duration::ZERO,
988                push_queue_cap: 1024,
989                download_chunks: download_chunks.iter().cloned(),
990                pull_timeout: Duration::from_millis(50),
991                min_chunk_size: 1,
992                max_speculative: 3,
993            },
994        );
995        drain(&result).await;
996        assert_eq!(&**receive.lock(), mock_data);
997    }
998
999    #[tokio::test(flavor = "multi_thread")]
1000    async fn test_concurrent_download_empty_chunks() {
1001        // Degenerate input: an empty `download_chunks` list must not hang or panic.
1002        // With no work queued, `set_threads` spawns zero workers and the event loop
1003        // ends once the push worker sees the closed channel.
1004        let mock_data = build_mock_data(3 * 1024);
1005        let puller = MockPuller::new(&mock_data);
1006        let pusher = MemPusher::with_capacity(mock_data.len());
1007        let receive = pusher.receive.clone();
1008        let result = download_multi(
1009            puller,
1010            pusher,
1011            DownloadOptions {
1012                concurrent: 32,
1013                retry_gap: Duration::from_secs(1),
1014                push_queue_cap: 1024,
1015                download_chunks: std::iter::empty(),
1016                pull_timeout: Duration::from_secs(5),
1017                min_chunk_size: 1,
1018                max_speculative: 3,
1019            },
1020        );
1021        timeout(Duration::from_secs(10), drain(&result))
1022            .await
1023            .expect("event loop hung on empty chunks");
1024        assert_eq!(receive.lock().len(), 0);
1025    }
1026}