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