Skip to main content

fast_pull/core/
single.rs

1//! Single-threaded sequential download.
2
3use crate::{
4    DownloadResult, Event, ProgressEntry, Puller, PullerError, Pusher, multi::TokioExecutor,
5};
6use bytes::Bytes;
7use core::time::Duration;
8use crossfire::{mpmc, spsc};
9use futures::TryStreamExt;
10use std::sync::Arc;
11use tokio_util::sync::CancellationToken;
12
13/// Options for a single-threaded download.
14#[derive(Debug, Clone, Copy)]
15pub struct DownloadOptions {
16    pub retry_gap: Duration,
17    pub push_queue_cap: usize,
18}
19
20/// Start a single-threaded sequential download.
21///
22/// The puller fetches the entire file sequentially, chunk by chunk.
23/// Supports retries and progress events via [`DownloadResult`].
24/// # Completion
25///
26/// The download is finished once the push driver has drained `rx_push` and
27/// flushed; the `event_chain` sender lives inside that driver, so it is dropped
28/// when the driver returns and the receiver disconnects. Draining
29/// `event_chain` is therefore the way to await completion. A panic in the
30/// blocking push driver also drops the sender and ends the session, but is
31/// otherwise swallowed. Normal cancellation (via
32/// `abort()`) is observed: the pull task is force-aborted and the push driver
33/// returns without flushing, so any buffered bytes are discarded and the file
34/// is left incomplete. Completion is still detected by draining `event_chain`,
35/// because the senders are dropped when both sides exit.
36#[allow(clippy::too_many_lines)]
37pub fn download_single<R: Puller, W: Pusher>(
38    mut puller: R,
39    mut pusher: W,
40    options: DownloadOptions,
41) -> DownloadResult<TokioExecutor<R, W::Error>, R::Error, W::Error> {
42    const ID: usize = 0;
43    let token = CancellationToken::new();
44    let (tx, event_chain) = mpmc::unbounded_async();
45    pusher.set_listener({
46        let tx = tx.clone();
47        Box::new(move |p| {
48            let _ = tx.send(Event::PushProgress(p));
49        })
50    });
51
52    let (tx_push, rx_push) =
53        spsc::bounded_async_blocking::<(ProgressEntry, Bytes)>(options.push_queue_cap);
54    let push_thread = Arc::new(std::sync::OnceLock::new());
55    let push_handle = tokio::task::spawn_blocking({
56        let push_thread = push_thread.clone();
57        let token = token.clone();
58        let tx = tx.clone();
59        move || {
60            let _ = push_thread.set(std::thread::current());
61            while let Ok((mut spin, mut data)) = rx_push.recv() {
62                loop {
63                    if token.is_cancelled() {
64                        return;
65                    }
66                    let _ = tx.send(Event::Pushing(ID, spin.clone()));
67                    let len_before_push = data.len();
68                    match pusher.push(&spin, data) {
69                        Ok(()) => break,
70                        Err((err, bytes)) => {
71                            let _ = tx.send(Event::PushError(ID, spin.clone(), err));
72                            let written = len_before_push.saturating_sub(bytes.len());
73                            data = bytes;
74                            spin.start += written as u64;
75                        }
76                    }
77                    std::thread::park_timeout(options.retry_gap);
78                }
79            }
80            loop {
81                if token.is_cancelled() {
82                    break;
83                }
84                let _ = tx.send(Event::Flushing);
85                match pusher.flush() {
86                    Ok(()) => break,
87                    Err(err) => {
88                        let _ = tx.send(Event::FlushError(err));
89                    }
90                }
91                std::thread::park_timeout(options.retry_gap);
92            }
93        }
94    });
95
96    let pull_handle = tokio::spawn(async move {
97        'redownload: loop {
98            let _ = tx.send(Event::Pulling(ID));
99            let mut downloaded: u64 = 0;
100            let mut stream = loop {
101                match puller.pull(None).await {
102                    Ok(t) => break t,
103                    Err((e, retry_gap)) => {
104                        let _ = tx.send(Event::PullError(ID, e));
105                        tokio::time::sleep(retry_gap.unwrap_or(options.retry_gap)).await;
106                    }
107                }
108            };
109            loop {
110                match stream.try_next().await {
111                    Ok(Some(chunk)) => {
112                        if chunk.is_empty() {
113                            continue;
114                        }
115                        let len = chunk.len() as u64;
116                        let span = downloaded..(downloaded + len);
117                        let _ = tx.send(Event::PullProgress(ID, span.clone()));
118                        let _ = tx_push.send((span, chunk)).await;
119                        downloaded += len;
120                    }
121                    Ok(None) => break 'redownload,
122                    Err((e, retry_gap)) => {
123                        let is_irrecoverable = e.is_irrecoverable();
124                        let _ = tx.send(Event::PullError(ID, e));
125                        tokio::time::sleep(retry_gap.unwrap_or(options.retry_gap)).await;
126                        if is_irrecoverable {
127                            continue 'redownload;
128                        }
129                    }
130                }
131            }
132        }
133        let _ = tx.send(Event::Finished(ID));
134    });
135
136    tokio::spawn({
137        let token = token.clone();
138        async move {
139            tokio::select! {
140                _ = push_handle => {},
141                () = token.cancelled() => {
142                    pull_handle.abort();
143                    if let Some(t) = push_thread.get() {
144                        t.unpark();
145                    }
146                }
147            }
148        }
149    });
150    DownloadResult::new(event_chain, None, token)
151}
152
153#[cfg(test)]
154mod tests {
155    #![allow(clippy::cast_possible_truncation)]
156    use super::*;
157    use crate::BufWriterPusher;
158    use crate::{
159        MemPusher, Merge, ProgressEntry,
160        mock::{MockPuller, build_mock_data},
161    };
162    use futures::stream;
163    use std::{dbg, vec};
164    use tokio::time::{sleep, timeout};
165    use vec::Vec;
166
167    #[tokio::test]
168    async fn test_sequential_download() {
169        let mock_data = build_mock_data(3 * 1024);
170        let puller = MockPuller::new(&mock_data);
171        let pusher = MemPusher::with_capacity(mock_data.len());
172        // Keep only the data handle for the final assertion; the whole `pusher`
173        // (whose listener holds a clone of the `event_chain` sender) is moved into
174        // the download, so `event_chain` closes once the push thread finishes,
175        // terminating the single drain loop below.
176        let receive = pusher.receive.clone();
177        #[allow(clippy::single_range_in_vec_init)]
178        let download_chunks = [0..mock_data.len() as u64];
179        let result = download_single(
180            puller,
181            pusher,
182            DownloadOptions {
183                retry_gap: Duration::from_secs(1),
184                push_queue_cap: 1024,
185            },
186        );
187
188        let mut pull_progress: Vec<ProgressEntry> = Vec::new();
189        let mut push_progress: Vec<ProgressEntry> = Vec::new();
190        while let Ok(e) = result.event_chain().recv().await {
191            match e {
192                Event::PullProgress(_, p) => pull_progress.merge_progress(p),
193                Event::PushProgress(p) => push_progress.merge_progress(p),
194                _ => {}
195            }
196        }
197        dbg!(&pull_progress);
198        dbg!(&push_progress);
199        assert_eq!(pull_progress, download_chunks);
200        assert_eq!(push_progress, download_chunks);
201
202        assert_eq!(&**receive.lock(), mock_data);
203    }
204
205    #[tokio::test]
206    async fn test_sequential_download_abort_discards() {
207        let mock_data = build_mock_data(3 * 1024);
208        let puller = MockPuller::new(&mock_data);
209        let pusher = MemPusher::with_capacity(mock_data.len());
210        // Keep the data handle for the post-abort length assertion; the whole
211        // `pusher` (whose listener holds a clone of the `event_chain` sender) is
212        // moved into the download.
213        let receive = pusher.receive.clone();
214        let result = download_single(
215            puller,
216            pusher,
217            DownloadOptions {
218                retry_gap: Duration::from_secs(1),
219                push_queue_cap: 1024,
220            },
221        );
222
223        // Abort immediately, before the pull/push machinery has a chance to
224        // finish. The push driver must observe the shared flag, break out
225        // WITHOUT flushing, and let `join()` return promptly.
226        result.abort();
227        assert!(result.is_aborted());
228
229        // The event loop must end promptly and never hang.
230        tokio::time::timeout(Duration::from_secs(10), async {
231            while result.event_chain().recv().await.is_ok() {}
232        })
233        .await
234        .expect("event loop hung after abort");
235
236        // The pusher layer is unchanged; for `MemPusher` each `push` is committed
237        // immediately, so abort simply stops further writes. The received bytes
238        // are a (possibly empty) prefix of the source and never exceed it — i.e.
239        // the download was cut short rather than completed.
240        let written = receive.lock().len();
241        assert!(
242            written <= mock_data.len(),
243            "abort must not write beyond the source"
244        );
245    }
246
247    // -------------------------------------------------------------------------
248    // Strengthened abort coverage.
249    //
250    // `BufWriterPusher` only forwards buffered bytes to its inner sink on
251    // `flush()` (or overflow). Wrapping `MemPusher` with capacity > source lets
252    // us prove that, on abort, the *un-flushed* buffer is discarded and nothing
253    // reaches the inner sink — an invariant the bare `MemPusher` test above
254    // cannot establish (for `MemPusher` every `push` is already committed).
255    // -------------------------------------------------------------------------
256
257    /// A [`Puller`] that stalls for `delay` before yielding any data, so a test
258    /// can deterministically abort *mid-flight* — after the push driver has
259    /// buffered bytes but before `pusher.flush()` would run.
260    #[derive(Debug, Clone)]
261    struct SlowMockPuller {
262        data: Arc<[u8]>,
263        delay: Duration,
264    }
265    impl Puller for SlowMockPuller {
266        type Error = std::convert::Infallible;
267        #[allow(clippy::cast_possible_truncation)]
268        fn pull(
269            &mut self,
270            range: Option<&ProgressEntry>,
271        ) -> impl Future<
272            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
273        > + Send {
274            type PullItem = crate::PullResult<Bytes, std::convert::Infallible>;
275            let owned: Vec<u8> = match range {
276                Some(r) => self.data[r.start as usize..r.end as usize].to_vec(),
277                None => self.data.to_vec(),
278            };
279            let delay = self.delay;
280            async move {
281                sleep(delay).await;
282                let items: Vec<PullItem> = owned
283                    .chunks(2)
284                    .map(|c| Ok(Bytes::from(c.to_vec())))
285                    .collect();
286                Ok(stream::iter(items))
287            }
288        }
289    }
290
291    #[tokio::test(flavor = "multi_thread")]
292    async fn test_sequential_download_abort_discards_buffered() {
293        // 64 KiB source. The slow puller stalls before yielding any data so the
294        // test can abort mid-flight (buffer filled, flush skipped).
295        let mock_data = build_mock_data(64 * 1024);
296        let puller = SlowMockPuller {
297            data: Arc::from(mock_data.as_slice()),
298            delay: Duration::from_millis(50),
299        };
300        // BufWriterPusher coalesces the contiguous single-stream writes; with
301        // capacity > source nothing reaches `MemPusher` until `flush()`.
302        let inner = MemPusher::with_capacity(mock_data.len());
303        let receive = inner.receive.clone();
304        let pusher = BufWriterPusher::new(inner, mock_data.len() + 1);
305        let result = download_single(
306            puller,
307            pusher,
308            DownloadOptions {
309                retry_gap: Duration::from_secs(1),
310                push_queue_cap: 1024,
311            },
312        );
313
314        // Abort as soon as the push driver starts processing (first `Pushing`
315        // event). This lands reliably before completion because the puller is
316        // slow.
317        let mut aborted = false;
318        while let Ok(e) = result.event_chain().recv().await {
319            if matches!(e, Event::Pushing(_, _)) {
320                result.abort();
321                assert!(result.is_aborted());
322                aborted = true;
323                break;
324            }
325        }
326        assert!(aborted, "expected a Pushing event before aborting");
327
328        // The event loop must end promptly and never hang.
329        timeout(Duration::from_secs(10), async {
330            while result.event_chain().recv().await.is_ok() {}
331        })
332        .await
333        .expect("event loop hung after abort");
334
335        // The buffered (un-flushed) bytes must have been discarded: the inner
336        // sink received nothing. This is the stronger invariant the bare
337        // `MemPusher` test could not prove.
338        let written = receive.lock().len();
339        assert_eq!(
340            written, 0,
341            "abort must discard buffered bytes, not write them to the sink"
342        );
343    }
344
345    #[cfg(feature = "file")]
346    #[tokio::test(flavor = "multi_thread")]
347    async fn test_sequential_download_abort_discards_file() {
348        use std::io::Read;
349        // 64 KiB source; real file sink pre-sized by `StdFilePusher::new`, then
350        // wrapped in a buffer. Abort must leave the file untouched (all zeros):
351        // the buffered bytes are discarded and never written to disk.
352        let mock_data = build_mock_data(64 * 1024);
353        let puller = SlowMockPuller {
354            data: Arc::from(mock_data.as_slice()),
355            delay: Duration::from_millis(50),
356        };
357        let tmp = tempfile::NamedTempFile::new().unwrap();
358        let path = tmp.path().to_path_buf();
359        let file = tokio::fs::File::from(tmp.reopen().unwrap());
360        let inner = crate::StdFilePusher::new(file, mock_data.len() as u64, false)
361            .await
362            .unwrap();
363        let pusher = BufWriterPusher::new(inner, mock_data.len() + 1);
364        let result = download_single(
365            puller,
366            pusher,
367            DownloadOptions {
368                retry_gap: Duration::from_secs(1),
369                push_queue_cap: 1024,
370            },
371        );
372
373        let mut aborted = false;
374        while let Ok(e) = result.event_chain().recv().await {
375            if matches!(e, Event::Pushing(_, _)) {
376                result.abort();
377                assert!(result.is_aborted());
378                aborted = true;
379                break;
380            }
381        }
382        assert!(aborted, "expected a Pushing event before aborting");
383
384        timeout(Duration::from_secs(10), async {
385            while result.event_chain().recv().await.is_ok() {}
386        })
387        .await
388        .expect("event loop hung after abort");
389
390        // No actual bytes were written: the file is still the zero-filled
391        // pre-sized region. This proves the buffered data was discarded rather
392        // than flushed to disk.
393        let mut f = std::fs::File::open(&path).unwrap();
394        let mut buf = Vec::new();
395        f.read_to_end(&mut buf).unwrap();
396        assert_eq!(buf.len(), mock_data.len(), "file should remain pre-sized");
397        assert!(
398            buf.iter().all(|&b| b == 0),
399            "abort must not write buffered bytes to the file"
400        );
401    }
402
403    // -------------------------------------------------------------------------
404    // Coverage for the push-error retry path (lines 54-59), flush-error retry path
405    // (lines 69-73), pull-error path (lines 83-85), stream-error path (lines 99-105)
406    // and the `SlowMockPuller` `Some` branch (line 247).
407    // -------------------------------------------------------------------------
408
409    use parking_lot::Mutex;
410    use std::sync::Arc;
411    use std::sync::atomic::{AtomicBool, Ordering};
412
413    #[derive(Debug)]
414    struct FatalErr;
415    impl std::fmt::Display for FatalErr {
416        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417            f.write_str("fatal")
418        }
419    }
420    impl std::error::Error for FatalErr {}
421    impl crate::PullerError for FatalErr {
422        fn is_irrecoverable(&self) -> bool {
423            true
424        }
425    }
426
427    #[derive(Debug)]
428    struct RecoverableErr;
429    impl std::fmt::Display for RecoverableErr {
430        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431            f.write_str("recoverable")
432        }
433    }
434    impl std::error::Error for RecoverableErr {}
435    impl crate::PullerError for RecoverableErr {
436        fn is_irrecoverable(&self) -> bool {
437            false
438        }
439    }
440
441    /// In-memory sink that can be told to fail the next `push` (lines 54-59) or
442    /// `flush` (lines 69-73).
443    struct FlakySink {
444        fail_push: Arc<AtomicBool>,
445        fail_flush: Arc<AtomicBool>,
446        receive: Arc<Mutex<Vec<u8>>>,
447        listener: Option<crate::ProgressListener>,
448    }
449    impl FlakySink {
450        fn new() -> Self {
451            Self {
452                fail_push: Arc::new(AtomicBool::new(false)),
453                fail_flush: Arc::new(AtomicBool::new(false)),
454                receive: Arc::new(Mutex::new(Vec::new())),
455                listener: None,
456            }
457        }
458    }
459    impl crate::Pusher for FlakySink {
460        type Error = std::io::Error;
461        fn set_listener(&mut self, cb: crate::ProgressListener) {
462            self.listener = Some(cb);
463        }
464        fn push(
465            &mut self,
466            range: &crate::ProgressEntry,
467            bytes: Bytes,
468        ) -> Result<(), (Self::Error, Bytes)> {
469            if self.fail_push.swap(false, Ordering::SeqCst) {
470                return Err((std::io::Error::other("push"), bytes));
471            }
472            let mut g = self.receive.lock();
473            if range.start as usize == g.len() {
474                g.extend_from_slice(&bytes);
475            } else {
476                if g.len() < range.end as usize {
477                    g.resize(range.end as usize, 0);
478                }
479                g[range.start as usize..range.end as usize].copy_from_slice(&bytes);
480            }
481            drop(g);
482            if let Some(l) = &mut self.listener {
483                l(range.clone());
484            }
485            Ok(())
486        }
487        fn flush(&mut self) -> Result<(), Self::Error> {
488            if self.fail_flush.swap(false, Ordering::SeqCst) {
489                Err(std::io::Error::other("flush"))
490            } else {
491                Ok(())
492            }
493        }
494    }
495
496    #[derive(Debug, Clone)]
497    struct PullErrOncePuller {
498        data: Arc<[u8]>,
499        failed: Arc<AtomicBool>,
500    }
501    impl crate::Puller for PullErrOncePuller {
502        type Error = RecoverableErr;
503        fn pull(
504            &mut self,
505            range: Option<&crate::ProgressEntry>,
506        ) -> impl Future<
507            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
508        > + Send {
509            if !self.failed.swap(true, Ordering::SeqCst) {
510                return std::future::ready(Err((RecoverableErr, Some(Duration::ZERO))));
511            }
512            let data = match range {
513                Some(r) => &self.data[r.start as usize..r.end as usize],
514                None => &self.data,
515            };
516            let items: Vec<crate::PullResult<Bytes, RecoverableErr>> = data
517                .chunks(2)
518                .map(|c| Ok(Bytes::copy_from_slice(c)))
519                .collect();
520            std::future::ready(Ok(stream::iter(items)))
521        }
522    }
523
524    #[derive(Debug, Clone)]
525    struct StreamErrOncePuller {
526        data: Arc<[u8]>,
527        failed: Arc<AtomicBool>,
528    }
529    impl crate::Puller for StreamErrOncePuller {
530        type Error = FatalErr;
531        fn pull(
532            &mut self,
533            range: Option<&crate::ProgressEntry>,
534        ) -> impl Future<
535            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
536        > + Send {
537            if !self.failed.swap(true, Ordering::SeqCst) {
538                let items: Vec<crate::PullResult<Bytes, FatalErr>> =
539                    vec![Err((FatalErr, Some(Duration::ZERO)))];
540                return std::future::ready(Ok(stream::iter(items)));
541            }
542            let data = match range {
543                Some(r) => &self.data[r.start as usize..r.end as usize],
544                None => &self.data,
545            };
546            let items: Vec<crate::PullResult<Bytes, FatalErr>> = data
547                .chunks(2)
548                .map(|c| Ok(Bytes::copy_from_slice(c)))
549                .collect();
550            std::future::ready(Ok(stream::iter(items)))
551        }
552    }
553
554    /// Like [`StreamErrOncePuller`] but yields a *recoverable* stream error first,
555    /// so the `is_irrecoverable == false` fall-through (line 105) is exercised.
556    #[derive(Debug, Clone)]
557    struct RecoverableStreamErrOncePuller {
558        data: Arc<[u8]>,
559        failed: Arc<AtomicBool>,
560    }
561    impl crate::Puller for RecoverableStreamErrOncePuller {
562        type Error = RecoverableErr;
563        fn pull(
564            &mut self,
565            range: Option<&crate::ProgressEntry>,
566        ) -> impl Future<
567            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
568        > + Send {
569            // The first `pull` yields one recoverable stream error *followed by* the
570            // real data in the same stream. In `single` a recoverable stream error
571            // falls through (line 105) and then keeps reading the same stream, so the
572            // download still completes successfully.
573            let first = !self.failed.swap(true, Ordering::SeqCst);
574            let data = match range {
575                Some(r) => &self.data[r.start as usize..r.end as usize],
576                None => &self.data,
577            };
578            let mut items: Vec<crate::PullResult<Bytes, RecoverableErr>> = data
579                .chunks(2)
580                .map(|c| Ok(Bytes::copy_from_slice(c)))
581                .collect();
582            if first {
583                let mut with_err = vec![Err((RecoverableErr, Some(Duration::ZERO)))];
584                with_err.append(&mut items);
585                return std::future::ready(Ok(stream::iter(with_err)));
586            }
587            std::future::ready(Ok(stream::iter(items)))
588        }
589    }
590
591    #[tokio::test]
592    async fn test_single_push_error_retries() {
593        // Lines 54-59: a failing inner push is retried after `park_timeout`.
594        let mock_data = build_mock_data(3 * 1024);
595        let puller = MockPuller::new(&mock_data);
596        let sink = FlakySink::new();
597        sink.fail_push.store(true, Ordering::SeqCst);
598        let receive = sink.receive.clone();
599        let result = download_single(
600            puller,
601            sink,
602            DownloadOptions {
603                retry_gap: Duration::ZERO,
604                push_queue_cap: 1024,
605            },
606        );
607        while result.event_chain().recv().await.is_ok() {}
608        assert_eq!(&**receive.lock(), mock_data);
609    }
610
611    #[tokio::test]
612    async fn test_single_flush_error_retries() {
613        // Lines 69-73: a failing inner flush is retried after `park_timeout`.
614        let mock_data = build_mock_data(3 * 1024);
615        let puller = MockPuller::new(&mock_data);
616        let sink = FlakySink::new();
617        sink.fail_flush.store(true, Ordering::SeqCst);
618        let receive = sink.receive.clone();
619        let result = download_single(
620            puller,
621            sink,
622            DownloadOptions {
623                retry_gap: Duration::ZERO,
624                push_queue_cap: 1024,
625            },
626        );
627        while result.event_chain().recv().await.is_ok() {}
628        assert_eq!(&**receive.lock(), mock_data);
629    }
630
631    #[tokio::test]
632    async fn test_single_pull_error_retries() {
633        // Lines 83-85: a `pull` error (recoverable) is retried.
634        let mock_data = build_mock_data(3 * 1024);
635        let puller = PullErrOncePuller {
636            data: Arc::from(mock_data.as_slice()),
637            failed: Arc::new(AtomicBool::new(false)),
638        };
639        let pusher = MemPusher::with_capacity(mock_data.len());
640        let receive = pusher.receive.clone();
641        let result = download_single(
642            puller,
643            pusher,
644            DownloadOptions {
645                retry_gap: Duration::ZERO,
646                push_queue_cap: 1024,
647            },
648        );
649        while result.event_chain().recv().await.is_ok() {}
650        assert_eq!(&**receive.lock(), mock_data);
651    }
652
653    #[tokio::test]
654    async fn test_single_stream_error_irrecoverable_retries() {
655        // Lines 99-105: a stream error whose `is_irrecoverable` is true triggers a
656        // `continue 'redownload` and a re-pull, which then succeeds.
657        let mock_data = build_mock_data(3 * 1024);
658        let puller = StreamErrOncePuller {
659            data: Arc::from(mock_data.as_slice()),
660            failed: Arc::new(AtomicBool::new(false)),
661        };
662        let pusher = MemPusher::with_capacity(mock_data.len());
663        let receive = pusher.receive.clone();
664        let result = download_single(
665            puller,
666            pusher,
667            DownloadOptions {
668                retry_gap: Duration::ZERO,
669                push_queue_cap: 1024,
670            },
671        );
672        while result.event_chain().recv().await.is_ok() {}
673        assert_eq!(&**receive.lock(), mock_data);
674    }
675
676    #[tokio::test]
677    async fn test_single_stream_error_recoverable_retries() {
678        // Lines 99-105: a stream error whose `is_irrecoverable` is false does NOT
679        // `continue 'redownload`; instead it falls through (line 105) and retries the
680        // pull, which then succeeds.
681        let mock_data = build_mock_data(3 * 1024);
682        let puller = RecoverableStreamErrOncePuller {
683            data: Arc::from(mock_data.as_slice()),
684            failed: Arc::new(AtomicBool::new(false)),
685        };
686        let pusher = MemPusher::with_capacity(mock_data.len());
687        let receive = pusher.receive.clone();
688        let result = download_single(
689            puller,
690            pusher,
691            DownloadOptions {
692                retry_gap: Duration::ZERO,
693                push_queue_cap: 1024,
694            },
695        );
696        while result.event_chain().recv().await.is_ok() {}
697        assert_eq!(&**receive.lock(), mock_data);
698    }
699
700    #[tokio::test]
701    async fn puller_and_error_coverage() {
702        // Exercise `Display` for the test error types (lines 386-388, 400-403) and
703        // both arms of each test puller's `match range` (lines 483, 511, 541, 550-551).
704        assert_eq!(format!("{FatalErr}"), "fatal");
705        assert_eq!(format!("{RecoverableErr}"), "recoverable");
706
707        let mut pull_err = PullErrOncePuller {
708            data: Arc::from(b"abcdef".as_slice()),
709            failed: Arc::new(AtomicBool::new(false)),
710        };
711        let _ = pull_err.pull(Some(&(0..2u64))).await; // first call errors, sets `failed`
712        let _ = pull_err.pull(Some(&(0..2u64))).await; // success path, `Some` arm
713        let _ = pull_err.pull(None).await; // success path, `None` arm
714
715        let mut stream_err = StreamErrOncePuller {
716            data: Arc::from(b"abcdef".as_slice()),
717            failed: Arc::new(AtomicBool::new(false)),
718        };
719        let _ = stream_err.pull(Some(&(0..2u64))).await;
720        let _ = stream_err.pull(Some(&(0..2u64))).await; // success path, `Some` arm
721        let _ = stream_err.pull(None).await; // success path, `None` arm
722
723        let mut rec_stream_err = RecoverableStreamErrOncePuller {
724            data: Arc::from(b"abcdef".as_slice()),
725            failed: Arc::new(AtomicBool::new(false)),
726        };
727        let _ = rec_stream_err.pull(Some(&(0..2u64))).await; // first: `Some` arm + error branch
728        let _ = rec_stream_err.pull(Some(&(0..2u64))).await; // not-first: `Some` arm
729        let _ = rec_stream_err.pull(None).await; // `None` arm
730    }
731
732    #[test]
733    fn flaky_sink_noncontiguous_write_rebuffers() {
734        // Lines 446-449: a write whose start is not flush against the current end of
735        // the sink takes the `else` branch (resize + scatter copy) instead of `extend`.
736        let mut sink = FlakySink::new();
737        sink.push(&(5..8u64), Bytes::from_static(b"xyz")).unwrap();
738        assert_eq!(&**sink.receive.lock(), b"\0\0\0\0\0xyz");
739    }
740
741    #[tokio::test]
742    async fn test_slow_mock_puller_some_range() {
743        // Line 247: the `Some` branch of `SlowMockPuller::pull` (the sequential
744        // download always passes `None`, so this branch is otherwise uncovered).
745        let mut p = SlowMockPuller {
746            data: Arc::from(b"hello world".as_slice()),
747            delay: Duration::ZERO,
748        };
749        assert!(p.pull(Some(&(0..5))).await.is_ok());
750    }
751
752    #[tokio::test]
753    async fn test_sequential_download_empty_file() {
754        // 0-byte source: the pull stream yields `Ok(None)` immediately, the
755        // `downloaded` counter stays 0, and the push worker must still flush and
756        // exit without hanging or writing anything.
757        let mock_data: Vec<u8> = Vec::new();
758        let puller = MockPuller::new(&mock_data);
759        let pusher = MemPusher::with_capacity(0);
760        let receive = pusher.receive.clone();
761        let result = download_single(
762            puller,
763            pusher,
764            DownloadOptions {
765                retry_gap: Duration::from_secs(1),
766                push_queue_cap: 1024,
767            },
768        );
769        // Drain events so `event_chain` does not pin the task open.
770        while result.event_chain().recv().await.is_ok() {}
771        timeout(Duration::from_secs(10), async {
772            while result.event_chain().recv().await.is_ok() {}
773        })
774        .await
775        .expect("event loop hung on empty file");
776        assert_eq!(receive.lock().len(), 0);
777    }
778
779    /// Like `PullErrOncePuller` but the first `pull` fails with `None` as the retry
780    /// gap, exercising the `retry_gap.unwrap_or(options.retry_gap)` fallback.
781    #[derive(Debug, Clone)]
782    struct PullErrNoGapPuller {
783        data: Arc<[u8]>,
784        failed: Arc<AtomicBool>,
785    }
786    impl crate::Puller for PullErrNoGapPuller {
787        type Error = RecoverableErr;
788        fn pull(
789            &mut self,
790            range: Option<&crate::ProgressEntry>,
791        ) -> impl Future<
792            Output = crate::PullResult<impl crate::PullStream<Self::Error>, Self::Error>,
793        > + Send {
794            if !self.failed.swap(true, Ordering::SeqCst) {
795                return std::future::ready(Err((RecoverableErr, None)));
796            }
797            let data = match range {
798                Some(r) => &self.data[r.start as usize..r.end as usize],
799                None => &self.data,
800            };
801            let items: Vec<crate::PullResult<Bytes, RecoverableErr>> = data
802                .chunks(2)
803                .map(|c| Ok(Bytes::copy_from_slice(c)))
804                .collect();
805            std::future::ready(Ok(stream::iter(items)))
806        }
807    }
808
809    #[tokio::test]
810    async fn test_single_pull_error_without_retry_gap_uses_options_default() {
811        // Lines 83-85: a `pull` error carrying `None` as the retry gap must fall
812        // back to `options.retry_gap` rather than panicking or stalling.
813        let mock_data = build_mock_data(3 * 1024);
814        let puller = PullErrNoGapPuller {
815            data: Arc::from(mock_data.as_slice()),
816            failed: Arc::new(AtomicBool::new(false)),
817        };
818        let pusher = MemPusher::with_capacity(mock_data.len());
819        let receive = pusher.receive.clone();
820        let result = download_single(
821            puller,
822            pusher,
823            DownloadOptions {
824                retry_gap: Duration::ZERO,
825                push_queue_cap: 1024,
826            },
827        );
828        while result.event_chain().recv().await.is_ok() {}
829        assert_eq!(&**receive.lock(), mock_data);
830    }
831
832    // -------------------------------------------------------------------------
833    // Regression baseline for the stalled-body hang (FluxDown-style #545).
834    // -------------------------------------------------------------------------
835
836    use crate::{PullResult, PullStream};
837    use std::pin::Pin;
838    use std::task::{Context, Poll};
839
840    /// A stream whose `next` never resolves — models a server that answers
841    /// the request (headers) but never delivers the body (a "stall"/"slowloris"
842    /// server). `TryStream` is satisfied automatically because `Item` is a
843    /// `Result`, so we only need to implement the underlying `Stream`.
844    struct PendingStream;
845    impl futures::Stream for PendingStream {
846        type Item = Result<Bytes, (std::convert::Infallible, Option<Duration>)>;
847        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
848            Poll::Pending
849        }
850    }
851    impl Unpin for PendingStream {}
852
853    /// A puller that returns a never-resolving stream (see [`PendingStream`]).
854    #[derive(Clone)]
855    struct StallPuller;
856    impl Puller for StallPuller {
857        type Error = std::convert::Infallible;
858        fn pull(
859            &mut self,
860            _range: Option<&ProgressEntry>,
861        ) -> impl Future<Output = PullResult<impl PullStream<Self::Error>, Self::Error>> + Send
862        {
863            std::future::ready(Ok(PendingStream))
864        }
865    }
866
867    /// `download_single` reads the body with a bare `stream.try_next().await`
868    /// (single.rs) and its `DownloadOptions` has no `pull_timeout` field, so a
869    /// stalled body blocks forever. This asserts the session ends (a
870    /// `PullTimeout`/error surfaces and the event channel closes) within 3s.
871    ///
872    /// `#[ignore]`d because the fix (adding `pull_timeout` to `DownloadOptions`
873    /// and giving up with an error instead of hanging) does not exist yet; remove
874    /// `#[ignore]` once that lands.
875    #[tokio::test]
876    #[ignore = "regression baseline: download_single has no pull_timeout, a stalled body hangs forever; enable after adding pull_timeout to DownloadOptions and surfacing a PullTimeout/error"]
877    async fn test_single_stall_body_hangs_without_timeout() {
878        let puller = StallPuller;
879        let pusher = MemPusher::with_capacity(0);
880        let result = download_single(
881            puller,
882            pusher,
883            DownloadOptions {
884                retry_gap: Duration::from_secs(1),
885                push_queue_cap: 1024,
886            },
887        );
888        // The session must not block forever: within 3s the event channel must
889        // close (a timeout/error must surface and end the download). Today it
890        // hangs, so the outer timeout fires and the assertion fails.
891        let drained = tokio::time::timeout(Duration::from_secs(3), async {
892            while result.event_chain().recv().await.is_ok() {}
893        })
894        .await;
895        assert!(
896            drained.is_ok(),
897            "download_single must not hang forever on a stalled body; a pull_timeout should surface a PullTimeout or error and end the session"
898        );
899    }
900}