Skip to main content

datui_lib/cloud/
download.rs

1//! Remote files downloaded to local ones, and stdin spooled to one. Reading and writing
2//! are on separate threads (writing on the caller's, never a runtime worker) with at most
3//! [`QUEUED_CHUNKS`] between them, so a slow disk backs the transfer up instead of
4//! memory. Store streams poll on the app's runtime; blocking readers (HTTP, stdin) get
5//! their own thread.
6
7use color_eyre::Result;
8use color_eyre::eyre::eyre;
9use std::path::Path;
10use std::sync::Arc;
11
12use crate::loading::unfinished::{Claim, Writer};
13
14/// A downloaded file, removed from disk when its last holder drops it: the app keeps
15/// one to read the file again, the dataset scanning it keeps another, and an event
16/// carrying one that is never handled removes it as it drops.
17#[derive(Debug, Clone)]
18pub struct TempDownload(Arc<Held>);
19
20/// The file, then the open's claim on it: dropped in that order, so the claim is let
21/// go only once the file is gone. See [`crate::loading::unfinished`].
22#[derive(Debug)]
23struct Held {
24    path: tempfile::TempPath,
25    _claim: Option<Claim>,
26    /// A shared lock on the file while it is held, so another datui sweeping old
27    /// spools ([`crate::loading::stdin`]) can tell a live one. Unix only: a lock there is
28    /// advisory, and reads and writes go on beside it.
29    #[cfg(unix)]
30    _lock: Option<std::fs::File>,
31}
32
33/// Whether another process holds the file at `path` ([`Held`]'s lock). A file that
34/// will not open is taken for held.
35#[cfg(unix)]
36pub(crate) fn held_elsewhere(path: &Path) -> bool {
37    use fs2::FileExt;
38    match std::fs::File::open(path) {
39        Ok(file) => file.try_lock_exclusive().is_err(),
40        Err(_) => true,
41    }
42}
43
44impl TempDownload {
45    /// An empty file in `dir` (the system temp directory when `None`) ending in
46    /// `.extension`, or `.tmp` without one. Removed if dropped before it is held.
47    pub fn create(dir: Option<&Path>, extension: Option<&str>) -> Result<tempfile::NamedTempFile> {
48        let dir = dir
49            .map(Path::to_path_buf)
50            .unwrap_or_else(std::env::temp_dir);
51        let suffix = extension
52            .map(|e| format!(".{e}"))
53            .unwrap_or_else(|| ".tmp".to_string());
54        tempfile::Builder::new()
55            .suffix(&suffix)
56            .tempfile_in(&dir)
57            .map_err(|_| eyre!("Could not create a temporary file."))
58    }
59
60    /// A finished file from [`Self::create`], closed and held.
61    #[cfg(test)]
62    pub fn keep(file: tempfile::NamedTempFile) -> TempDownload {
63        Self::held(file, None)
64    }
65
66    pub(crate) fn held(file: tempfile::NamedTempFile, claim: Option<Claim>) -> TempDownload {
67        #[cfg(unix)]
68        let lock = file.as_file().try_clone().ok().filter(|lock| {
69            use fs2::FileExt;
70            FileExt::try_lock_shared(lock).is_ok()
71        });
72        TempDownload(Arc::new(Held {
73            path: file.into_temp_path(),
74            _claim: claim,
75            #[cfg(unix)]
76            _lock: lock,
77        }))
78    }
79
80    pub fn path(&self) -> &Path {
81        &self.0.path
82    }
83}
84
85/// Chunks queued between the side reading a download and the thread writing it.
86pub const QUEUED_CHUNKS: usize = 4;
87
88/// How often a download that has gone quiet checks whether it was stopped.
89const STALL_CHECK: std::time::Duration = std::time::Duration::from_millis(100);
90
91/// What opening a download answers: its stream or reader and its length, when the
92/// source gave one, or why it could not be opened.
93pub type Opened<S> = std::result::Result<(S, Option<u64>), String>;
94
95/// Why a download did not arrive whole.
96#[derive(Debug)]
97pub enum StreamError {
98    /// The request failed, or the opener turned its answer down.
99    Open(String),
100    /// A chunk failed partway.
101    Read(String),
102    /// The writer refused a chunk.
103    Write(color_eyre::Report),
104    /// The stream ended short of the length the store gave.
105    Short { expected: u64, got: u64 },
106    /// Stopped, or cut off by the runtime shutting down.
107    Cut,
108}
109
110/// What the reading side hands the writer, in order.
111enum Piece<B> {
112    /// The request was answered; the length, when the source gave one.
113    Opened(Option<u64>),
114    Refused(String),
115    Chunk(B),
116    Failed(String),
117    End,
118}
119
120/// Hand each chunk `next` gives to `write`, in order, until the end. `next` answers
121/// `None` once the reading side is gone or `stop` says so. Returns the bytes written.
122fn receive<B: AsRef<[u8]>>(
123    stop: &impl Fn() -> bool,
124    mut next: impl FnMut() -> Option<Piece<B>>,
125    mut write: impl FnMut(&[u8]) -> Result<()>,
126) -> std::result::Result<u64, StreamError> {
127    let mut expected = None;
128    let mut written = 0u64;
129    loop {
130        if stop() {
131            return Err(StreamError::Cut);
132        }
133        match next() {
134            Some(Piece::Opened(len)) => expected = len,
135            Some(Piece::Refused(error)) => return Err(StreamError::Open(error)),
136            Some(Piece::Chunk(chunk)) => {
137                let chunk = chunk.as_ref();
138                write(chunk).map_err(StreamError::Write)?;
139                written += chunk.len() as u64;
140            }
141            Some(Piece::Failed(error)) => return Err(StreamError::Read(error)),
142            Some(Piece::End) => break,
143            None => return Err(StreamError::Cut),
144        }
145    }
146    match expected {
147        Some(expected) if expected != written => Err(StreamError::Short {
148            expected,
149            got: written,
150        }),
151        _ => Ok(written),
152    }
153}
154
155/// Write each chunk `fill` hands its writer into `file`, then flush it. Returns what
156/// `fill` counted; a failed write or flush is a [`StreamError::Write`] of `unwritable`.
157pub(crate) fn fill_file(
158    file: &mut impl std::io::Write,
159    unwritable: impl Fn(std::io::Error) -> color_eyre::Report,
160    fill: impl FnOnce(&mut dyn FnMut(&[u8]) -> Result<()>) -> std::result::Result<u64, StreamError>,
161) -> std::result::Result<u64, StreamError> {
162    let written = fill(&mut |chunk| file.write_all(chunk).map_err(&unwritable))?;
163    file.flush()
164        .map_err(|e| StreamError::Write(unwritable(e)))?;
165    Ok(written)
166}
167
168/// A new file in `dir` (named as [`TempDownload::create`] does) filled by `fill`. Any
169/// failure or stop removes the partial file before returning. Claimed via `writer` from
170/// creation until its last holder drops it, so quitting removes it mid-write (see
171/// [`crate::loading::unfinished`]); a stopped open's file is refused and removed.
172fn fill_temp(
173    dir: Option<&Path>,
174    extension: Option<&str>,
175    writer: &Writer,
176    fill: impl FnOnce(&mut dyn FnMut(&[u8]) -> Result<()>) -> std::result::Result<u64, StreamError>,
177) -> std::result::Result<TempDownload, StreamError> {
178    let Some((mut file, claim)) = writer
179        .create(|| TempDownload::create(dir, extension))
180        .map_err(StreamError::Write)?
181    else {
182        return Err(StreamError::Cut);
183    };
184    let unwritable = |e: std::io::Error| eyre!("Could not write the downloaded file: {e}");
185    match fill_file(&mut file, unwritable, fill) {
186        Ok(_) => Ok(TempDownload::held(file, Some(claim))),
187        Err(e) => {
188            // The file before the claim, so a sweep never finds it let go but there.
189            drop(file);
190            drop(claim);
191            Err(e)
192        }
193    }
194}
195
196/// Run `open` on `runtime` and pass each stream chunk to `write` on this thread, in
197/// order, returning the bytes written. `stop` (and whether this side still listens) is
198/// checked between chunks and every `STALL_CHECK` of silence. Errors, never a short
199/// success, on open or chunk failure, a refused write, a stop, or runtime shutdown.
200/// Blocks: never call on a runtime worker.
201#[cfg(feature = "cloud")]
202pub fn stream_into<O, S, B, E>(
203    runtime: &tokio::runtime::Handle,
204    open: O,
205    stop: impl Fn() -> bool + Clone + Send + Sync + 'static,
206    write: impl FnMut(&[u8]) -> Result<()>,
207) -> std::result::Result<u64, StreamError>
208where
209    O: std::future::Future<Output = Opened<S>> + Send + 'static,
210    S: futures::Stream<Item = std::result::Result<B, E>> + Send + 'static,
211    B: AsRef<[u8]> + Send + 'static,
212    E: std::fmt::Display,
213{
214    let (tx, mut rx) = tokio::sync::mpsc::channel(QUEUED_CHUNKS);
215    let stopped = stop.clone();
216    // Spawned rather than polled from here: a shutdown drops the task, and the
217    // closed channel ends the wait below, where a future polled on this thread would
218    // touch a runtime that is gone.
219    runtime.spawn(async move {
220        // A writer that gave up has dropped the queue; a store gone quiet would
221        // otherwise hold the request open until it answered.
222        let gone = || stopped() || tx.is_closed();
223        let (stream, len) = match until_stopped(open, &gone).await {
224            Some(Ok(opened)) => opened,
225            Some(Err(error)) => {
226                let _ = tx.send(Piece::Refused(error)).await;
227                return;
228            }
229            None => return,
230        };
231        if tx.send(Piece::Opened(len)).await.is_err() {
232            return;
233        }
234        let mut stream = std::pin::pin!(stream);
235        loop {
236            let next = futures::StreamExt::next(&mut stream);
237            let piece = match until_stopped(next, &gone).await {
238                None => return,
239                Some(Some(Ok(chunk))) => Piece::Chunk(chunk),
240                Some(Some(Err(error))) => Piece::Failed(error.to_string()),
241                Some(None) => Piece::End,
242            };
243            let last = !matches!(piece, Piece::Chunk(_));
244            // Waits while the queue is full, which is the backpressure: the stream is
245            // not polled again until the writer has taken a chunk.
246            if tx.send(piece).await.is_err() || last {
247                return;
248            }
249        }
250    });
251    receive(&stop, || rx.blocking_recv(), write)
252}
253
254/// `future`'s output, or `None` once `stop` says so while it is still pending.
255#[cfg(feature = "cloud")]
256async fn until_stopped<F: std::future::Future>(
257    future: F,
258    stop: &impl Fn() -> bool,
259) -> Option<F::Output> {
260    let mut future = std::pin::pin!(future);
261    loop {
262        if stop() {
263            return None;
264        }
265        if let Ok(output) = tokio::time::timeout(STALL_CHECK, future.as_mut()).await {
266            return Some(output);
267        }
268    }
269}
270
271/// Stream the object `open` answers with into a new file in `dir`, as
272/// [`TempDownload::create`] names it, until `writer`'s open stops. Any failure, and a
273/// stop, removes the partial file before this returns.
274#[cfg(feature = "cloud")]
275pub(crate) fn stream_to_temp<O, S, B, E>(
276    runtime: &tokio::runtime::Handle,
277    dir: Option<&Path>,
278    extension: Option<&str>,
279    open: O,
280    writer: &Writer,
281) -> std::result::Result<TempDownload, StreamError>
282where
283    O: std::future::Future<Output = Opened<S>> + Send + 'static,
284    S: futures::Stream<Item = std::result::Result<B, E>> + Send + 'static,
285    B: AsRef<[u8]> + Send + 'static,
286    E: std::fmt::Display,
287{
288    let stop = {
289        let writer = writer.clone();
290        move || writer.stopped()
291    };
292    fill_temp(dir, extension, writer, |write| {
293        stream_into(runtime, open, stop, write)
294    })
295}
296
297/// Bytes asked of a blocking reader at a time.
298const READ_CHUNK: usize = 64 * 1024;
299
300/// Run `open` on its own thread and pass each chunk to `write` here, in order, returning
301/// the bytes written (a stated length must match). Reads run at most [`QUEUED_CHUNKS`]
302/// ahead; a silent server holds the reader, not this thread, with `stop` checked between
303/// chunks and every `STALL_CHECK`. The reader ends at its next chunk once this side goes.
304pub fn read_into<R: std::io::Read>(
305    open: impl FnOnce() -> Opened<R> + Send + 'static,
306    stop: impl Fn() -> bool,
307    write: impl FnMut(&[u8]) -> Result<()>,
308) -> std::result::Result<u64, StreamError> {
309    use std::sync::mpsc::RecvTimeoutError;
310
311    let (tx, rx) = std::sync::mpsc::sync_channel(QUEUED_CHUNKS);
312    std::thread::Builder::new()
313        .name("datui-download".to_string())
314        .spawn(move || {
315            let mut reader = match open() {
316                Ok((reader, len)) => {
317                    if tx.send(Piece::Opened(len)).is_err() {
318                        return;
319                    }
320                    reader
321                }
322                Err(error) => {
323                    let _ = tx.send(Piece::Refused(error));
324                    return;
325                }
326            };
327            loop {
328                let mut chunk = vec![0; READ_CHUNK];
329                let piece = match reader.read(&mut chunk) {
330                    Ok(0) => Piece::End,
331                    Ok(read) => {
332                        chunk.truncate(read);
333                        Piece::Chunk(chunk)
334                    }
335                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
336                    Err(error) => Piece::Failed(error.to_string()),
337                };
338                let last = !matches!(piece, Piece::Chunk(_));
339                if tx.send(piece).is_err() || last {
340                    return;
341                }
342            }
343        })
344        .map_err(|e| StreamError::Open(e.to_string()))?;
345    let next = || loop {
346        match rx.recv_timeout(STALL_CHECK) {
347            Ok(piece) => return Some(piece),
348            Err(RecvTimeoutError::Timeout) if !stop() => {}
349            Err(RecvTimeoutError::Timeout) => return None,
350            // Only a reader that panicked leaves without saying how it ended.
351            Err(RecvTimeoutError::Disconnected) => {
352                return Some(Piece::Failed("the download stopped".to_string()));
353            }
354        }
355    };
356    receive(&stop, next, write)
357}
358
359/// Read `open`'s stream into a new file in `dir` until `writer`'s open stops (see
360/// [`read_into`]); failures and stops remove the partial file. With `limit`, more bytes
361/// than that fail as [`StreamError::Write`] of [`PastLimit`], nothing past it written.
362#[cfg(feature = "http")]
363pub(crate) fn read_to_temp<R: std::io::Read>(
364    dir: Option<&Path>,
365    extension: Option<&str>,
366    open: impl FnOnce() -> Opened<R> + Send + 'static,
367    writer: &Writer,
368    limit: Option<u64>,
369) -> std::result::Result<TempDownload, StreamError> {
370    let mut arrived = 0u64;
371    fill_temp(dir, extension, writer, |write| {
372        read_into(
373            open,
374            || writer.stopped(),
375            |chunk| {
376                arrived += chunk.len() as u64;
377                if limit.is_some_and(|limit| arrived > limit) {
378                    return Err(color_eyre::Report::new(PastLimit));
379                }
380                write(chunk)
381            },
382        )
383    })
384}
385
386/// A download stopped at its limit: the open asks before fetching more.
387#[cfg(any(feature = "http", feature = "cloud"))]
388#[derive(Debug)]
389pub(crate) struct PastLimit;
390
391#[cfg(any(feature = "http", feature = "cloud"))]
392impl std::fmt::Display for PastLimit {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.write_str("The download passed the size it may fetch without asking.")
395    }
396}
397
398#[cfg(any(feature = "http", feature = "cloud"))]
399impl std::error::Error for PastLimit {}
400
401/// Read what `open` answers with into a new file in `dir` until it ends or `writer`'s
402/// open stops, adding each chunk's length to `read` as it lands; see [`read_into`].
403/// Any failure, and a stop, removes the partial file before this returns.
404pub(crate) fn spool_to_temp<R: std::io::Read>(
405    dir: Option<&Path>,
406    open: impl FnOnce() -> Opened<R> + Send + 'static,
407    writer: &Writer,
408    read: &std::sync::atomic::AtomicU64,
409) -> std::result::Result<TempDownload, StreamError> {
410    use std::sync::atomic::Ordering;
411    fill_temp(dir, None, writer, |write| {
412        read_into(
413            open,
414            || writer.stopped(),
415            |chunk| {
416                write(chunk)?;
417                read.fetch_add(chunk.len() as u64, Ordering::Relaxed);
418                Ok(())
419            },
420        )
421    })
422}
423
424#[cfg(all(test, feature = "cloud"))]
425mod tests {
426    use super::*;
427    use futures::StreamExt;
428    use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
429
430    const CHUNK: usize = 64 * 1024;
431
432    fn runtime() -> tokio::runtime::Runtime {
433        tokio::runtime::Builder::new_multi_thread()
434            .worker_threads(1)
435            .enable_all()
436            .build()
437            .expect("runtime")
438    }
439
440    /// Chunk `i` of a test object: bytes that differ by position, so a reordered or
441    /// dropped chunk shows.
442    fn chunk(i: usize, len: usize) -> Vec<u8> {
443        (0..len).map(|j| ((i * 31 + j * 7) % 251) as u8).collect()
444    }
445
446    /// Bytes alive in chunks the stream has made and nobody has dropped yet, and the
447    /// most there ever were: what the transfer held in memory at its peak.
448    #[derive(Default)]
449    struct Live {
450        now: AtomicU64,
451        peak: AtomicU64,
452    }
453
454    struct Tracked(Vec<u8>, Arc<Live>);
455
456    impl Tracked {
457        fn new(bytes: Vec<u8>, live: &Arc<Live>) -> Tracked {
458            let now = live.now.fetch_add(bytes.len() as u64, Ordering::SeqCst) + bytes.len() as u64;
459            live.peak.fetch_max(now, Ordering::SeqCst);
460            Tracked(bytes, live.clone())
461        }
462    }
463
464    impl Drop for Tracked {
465        fn drop(&mut self) {
466            self.1.now.fetch_sub(self.0.len() as u64, Ordering::SeqCst);
467        }
468    }
469
470    impl AsRef<[u8]> for Tracked {
471        fn as_ref(&self) -> &[u8] {
472            &self.0
473        }
474    }
475
476    /// Sets its flag when dropped: when a stream holding it was let go.
477    struct DropFlag(Arc<AtomicBool>);
478
479    impl Drop for DropFlag {
480        fn drop(&mut self) {
481            self.0.store(true, Ordering::SeqCst);
482        }
483    }
484
485    async fn opened<S>(stream: S, len: Option<u64>) -> Opened<S> {
486        Ok((stream, len))
487    }
488
489    fn never() -> impl Fn() -> bool + Clone + Send + Sync + 'static {
490        || false
491    }
492
493    /// A writer whose open is never stopped.
494    fn unstopped() -> Writer {
495        Writer::default()
496    }
497
498    fn files_in(dir: &Path) -> usize {
499        std::fs::read_dir(dir).unwrap().count()
500    }
501
502    /// Many chunks land in order, byte for byte, in a file named for the format.
503    #[test]
504    fn a_stream_lands_whole_in_order() {
505        let rt = runtime();
506        let dir = tempfile::tempdir().unwrap();
507        let chunks = (0..40).map(|i| chunk(i, 1000 + i * 13)).collect::<Vec<_>>();
508        let whole = chunks.concat();
509        let stream = futures::stream::iter(chunks.into_iter().map(Ok::<_, String>));
510        let file = stream_to_temp(
511            rt.handle(),
512            Some(dir.path()),
513            Some("csv.gz"),
514            opened(stream, Some(whole.len() as u64)),
515            &unstopped(),
516        )
517        .unwrap();
518        assert_eq!(std::fs::read(file.path()).unwrap(), whole);
519        assert!(file.path().to_string_lossy().ends_with(".csv.gz"));
520        assert_eq!(file.path().parent(), Some(dir.path()));
521        let held = file.clone();
522        drop(file);
523        assert!(held.path().exists(), "a holder keeps it");
524        let path = held.path().to_path_buf();
525        drop(held);
526        assert!(!path.exists(), "the last holder removes it");
527    }
528
529    /// A download is claimed from the moment its file exists until its last holder lets
530    /// it go, so quitting finds it whoever holds it; an open already stopped gets no
531    /// file at all.
532    #[test]
533    fn a_download_is_claimed_for_as_long_as_it_lives() {
534        let rt = runtime();
535        let dir = tempfile::tempdir().unwrap();
536        let unfinished = crate::loading::unfinished::Unfinished::default();
537        let stop = Arc::new(AtomicBool::new(false));
538        let writer = unfinished.writer(stop.clone());
539        let stream = futures::stream::iter(vec![Ok::<_, String>(chunk(0, 10))]);
540        let file = stream_to_temp(
541            rt.handle(),
542            Some(dir.path()),
543            None,
544            opened(stream, None),
545            &writer,
546        )
547        .unwrap();
548        let held = file.clone();
549        drop(file);
550        assert!(unfinished.writing(), "a holder keeps the claim");
551        let path = held.path().to_path_buf();
552        drop(held);
553        assert!(!path.exists());
554        assert!(!unfinished.writing(), "the claim goes with the file");
555
556        stop.store(true, Ordering::SeqCst);
557        let stream = futures::stream::iter(vec![Ok::<_, String>(chunk(0, 10))]);
558        let error = stream_to_temp(
559            rt.handle(),
560            Some(dir.path()),
561            None,
562            opened(stream, None),
563            &writer,
564        )
565        .unwrap_err();
566        assert!(matches!(error, StreamError::Cut), "{error:?}");
567        assert_eq!(files_in(dir.path()), 0);
568    }
569
570    /// A slow writer holds the stream back: the store is never more than the queue
571    /// ahead of the disk, and no more than a few chunks are in memory at once however
572    /// large the object.
573    #[test]
574    fn a_slow_writer_holds_the_stream_back() {
575        let rt = runtime();
576        let live = Arc::new(Live::default());
577        let pulled = Arc::new(AtomicUsize::new(0));
578        let chunks = 64;
579        let stream = {
580            let (live, pulled) = (live.clone(), pulled.clone());
581            futures::stream::iter(0..chunks).map(move |i| {
582                pulled.fetch_add(1, Ordering::SeqCst);
583                Ok::<_, String>(Tracked::new(chunk(i, CHUNK), &live))
584            })
585        };
586        let mut written = 0usize;
587        let mut ahead = 0usize;
588        let mut bytes = Vec::new();
589        let total = stream_into(rt.handle(), opened(stream, None), never(), |piece| {
590            std::thread::sleep(std::time::Duration::from_millis(2));
591            written += 1;
592            ahead = ahead.max(pulled.load(Ordering::SeqCst) - written);
593            bytes.extend_from_slice(piece);
594            Ok(())
595        })
596        .unwrap();
597        assert_eq!(total, (chunks * CHUNK) as u64);
598        assert_eq!(
599            bytes,
600            (0..chunks)
601                .flat_map(|i| chunk(i, CHUNK))
602                .collect::<Vec<_>>()
603        );
604        // The queue, one chunk waiting to go into it and one being written.
605        let bound = QUEUED_CHUNKS + 2;
606        assert!(ahead <= bound, "the store ran {ahead} chunks ahead");
607        let peak = live.peak.load(Ordering::SeqCst);
608        assert!(
609            peak <= (bound * CHUNK) as u64,
610            "{peak} bytes held at once, of {} streamed",
611            chunks * CHUNK
612        );
613        assert_eq!(live.now.load(Ordering::SeqCst), 0, "every chunk was let go");
614    }
615
616    /// A chunk that fails partway fails the download and leaves no file.
617    #[test]
618    fn a_failure_mid_stream_leaves_no_file() {
619        let rt = runtime();
620        let dir = tempfile::tempdir().unwrap();
621        let stream = futures::stream::iter(vec![
622            Ok(chunk(0, CHUNK)),
623            Ok(chunk(1, CHUNK)),
624            Err("connection reset".to_string()),
625            Ok(chunk(3, CHUNK)),
626        ]);
627        let error = stream_to_temp(
628            rt.handle(),
629            Some(dir.path()),
630            None,
631            opened(stream, None),
632            &unstopped(),
633        )
634        .unwrap_err();
635        assert!(
636            matches!(&error, StreamError::Read(e) if e == "connection reset"),
637            "{error:?}"
638        );
639        assert_eq!(files_in(dir.path()), 0);
640
641        let refused = async {
642            Err::<(futures::stream::Empty<Result<Vec<u8>, String>>, _), _>("403".to_string())
643        };
644        let error =
645            stream_to_temp(rt.handle(), Some(dir.path()), None, refused, &unstopped()).unwrap_err();
646        assert!(
647            matches!(&error, StreamError::Open(e) if e == "403"),
648            "{error:?}"
649        );
650        assert_eq!(files_in(dir.path()), 0);
651
652        // Ended early without saying so: short of the length the store gave.
653        let stream = futures::stream::iter(vec![Ok::<_, String>(chunk(0, 10))]);
654        let error = stream_to_temp(
655            rt.handle(),
656            Some(dir.path()),
657            None,
658            opened(stream, Some(20)),
659            &unstopped(),
660        )
661        .unwrap_err();
662        assert!(
663            matches!(
664                error,
665                StreamError::Short {
666                    expected: 20,
667                    got: 10
668                }
669            ),
670            "{error:?}"
671        );
672        assert_eq!(files_in(dir.path()), 0);
673    }
674
675    /// A disk that refuses a write ends the transfer there: the store is not read to
676    /// the end for nothing.
677    #[test]
678    fn a_refused_write_stops_the_stream() {
679        let rt = runtime();
680        let pulled = Arc::new(AtomicUsize::new(0));
681        let ended = Arc::new(AtomicBool::new(false));
682        let stream = {
683            let pulled = pulled.clone();
684            let guard = DropFlag(ended.clone());
685            futures::stream::iter(0..1000).map(move |i| {
686                let _ = &guard;
687                pulled.fetch_add(1, Ordering::SeqCst);
688                Ok::<_, String>(chunk(i, 1024))
689            })
690        };
691        let mut writes = 0;
692        let error = stream_into(rt.handle(), opened(stream, None), never(), |_| {
693            writes += 1;
694            if writes == 3 {
695                return Err(eyre!("No space left on device"));
696            }
697            Ok(())
698        })
699        .unwrap_err();
700        assert!(matches!(&error, StreamError::Write(e) if e.to_string().contains("No space")));
701        // The task sees the closed queue on its next send, and lets the stream go.
702        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
703        while !ended.load(Ordering::SeqCst) {
704            assert!(
705                std::time::Instant::now() < deadline,
706                "the stream is still read"
707            );
708            std::thread::sleep(std::time::Duration::from_millis(5));
709        }
710        let pulled = pulled.load(Ordering::SeqCst);
711        assert!(pulled <= 3 + QUEUED_CHUNKS + 2, "{pulled} chunks read");
712
713        // A store that goes quiet after the refused chunk is let go all the same,
714        // rather than held open until it answers.
715        let dropped = Arc::new(AtomicBool::new(false));
716        let stream = {
717            let guard = DropFlag(dropped.clone());
718            futures::stream::iter(vec![Ok::<_, String>(chunk(0, 1024))])
719                .chain(futures::stream::pending())
720                .map(move |chunk| {
721                    let _ = &guard;
722                    chunk
723                })
724        };
725        let error = stream_into(rt.handle(), opened(stream, None), never(), |_| {
726            Err(eyre!("No space left on device"))
727        })
728        .unwrap_err();
729        assert!(matches!(error, StreamError::Write(_)));
730        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
731        while !dropped.load(Ordering::SeqCst) {
732            assert!(
733                std::time::Instant::now() < deadline,
734                "the request is still open"
735            );
736            std::thread::sleep(std::time::Duration::from_millis(10));
737        }
738
739        // And a file that cannot be created is the same refusal, before any request.
740        let missing = tempfile::tempdir().unwrap().path().join("gone");
741        let asked = Arc::new(AtomicBool::new(false));
742        let open = {
743            let asked = asked.clone();
744            async move {
745                asked.store(true, Ordering::SeqCst);
746                Ok((futures::stream::empty::<Result<Vec<u8>, String>>(), None))
747            }
748        };
749        let error =
750            stream_to_temp(rt.handle(), Some(&missing), None, open, &unstopped()).unwrap_err();
751        assert!(matches!(error, StreamError::Write(_)));
752        assert!(!asked.load(Ordering::SeqCst));
753    }
754
755    /// A stop ends the download between chunks, and while the store is silent, and
756    /// either way the partial file goes.
757    #[test]
758    fn a_stop_ends_the_download_and_removes_the_file() {
759        let rt = runtime();
760        let dir = tempfile::tempdir().unwrap();
761        let stop = Arc::new(AtomicBool::new(false));
762        let stopped = crate::loading::unfinished::Unfinished::default().writer(stop.clone());
763
764        let stream = {
765            let stop = stop.clone();
766            futures::stream::iter(0..100).map(move |i| {
767                if i == 3 {
768                    stop.store(true, Ordering::SeqCst);
769                }
770                Ok::<_, String>(chunk(i, CHUNK))
771            })
772        };
773        let error = stream_to_temp(
774            rt.handle(),
775            Some(dir.path()),
776            None,
777            opened(stream, None),
778            &stopped,
779        )
780        .unwrap_err();
781        assert!(matches!(error, StreamError::Cut), "{error:?}");
782        assert_eq!(files_in(dir.path()), 0);
783
784        // One chunk, then nothing: a store that has stopped answering.
785        stop.store(false, Ordering::SeqCst);
786        let stream = futures::stream::iter(vec![Ok::<_, String>(chunk(0, CHUNK))])
787            .chain(futures::stream::pending());
788        // Stopped once the first chunk is on disk and the store has gone quiet.
789        let stopper = {
790            let (stop, dir) = (stop.clone(), dir.path().to_path_buf());
791            std::thread::spawn(move || {
792                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
793                while !std::fs::read_dir(&dir).unwrap().any(|f| {
794                    std::fs::metadata(f.unwrap().path()).map_or(0, |m| m.len()) == CHUNK as u64
795                }) && std::time::Instant::now() < deadline
796                {
797                    std::thread::sleep(std::time::Duration::from_millis(5));
798                }
799                stop.store(true, Ordering::SeqCst);
800            })
801        };
802        let began = std::time::Instant::now();
803        let error = stream_to_temp(
804            rt.handle(),
805            Some(dir.path()),
806            None,
807            opened(stream, None),
808            &stopped,
809        )
810        .unwrap_err();
811        stopper.join().unwrap();
812        assert!(matches!(error, StreamError::Cut), "{error:?}");
813        assert!(began.elapsed() < std::time::Duration::from_secs(5));
814        assert_eq!(files_in(dir.path()), 0);
815    }
816
817    /// A runtime shut down mid-transfer, as quitting does, cuts the download off: the
818    /// waiting thread gets an error rather than a panic or a short file, and the file
819    /// goes.
820    #[test]
821    fn a_shutdown_mid_transfer_cuts_it_off() {
822        let rt = runtime();
823        let dir = tempfile::tempdir().unwrap();
824        let stream = futures::stream::iter(vec![Ok::<_, String>(chunk(0, CHUNK))])
825            .chain(futures::stream::pending());
826        let handle = rt.handle().clone();
827        let path = dir.path().to_path_buf();
828        let waiter = std::thread::spawn(move || {
829            stream_to_temp(
830                &handle,
831                Some(&path),
832                None,
833                opened(stream, None),
834                &unstopped(),
835            )
836        });
837        // Mid-transfer: the first chunk is on disk and the store has gone quiet. The
838        // size comes from the file, not the directory entry, which on Windows changes
839        // only when the writer closes it.
840        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
841        while !std::fs::read_dir(dir.path())
842            .unwrap()
843            .any(|f| std::fs::metadata(f.unwrap().path()).map_or(0, |m| m.len()) == CHUNK as u64)
844        {
845            assert!(
846                std::time::Instant::now() < deadline,
847                "the first chunk never landed"
848            );
849            std::thread::sleep(std::time::Duration::from_millis(5));
850        }
851        rt.shutdown_background();
852        let error = waiter.join().expect("no panic").unwrap_err();
853        assert!(matches!(error, StreamError::Cut), "{error:?}");
854        assert_eq!(files_in(dir.path()), 0);
855    }
856}
857
858#[cfg(all(test, feature = "http"))]
859mod read_tests {
860    use super::*;
861    use std::io::Read;
862    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
863    use std::time::{Duration, Instant};
864
865    /// Hands out `chunks` one per read, then blocks until `release` sends or is
866    /// dropped, then ends. Counts its reads, and says when it is dropped.
867    struct Source {
868        chunks: std::vec::IntoIter<Vec<u8>>,
869        release: Option<std::sync::mpsc::Receiver<()>>,
870        reads: Arc<AtomicUsize>,
871        dropped: Arc<AtomicBool>,
872    }
873
874    impl Read for Source {
875        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
876            self.reads.fetch_add(1, Ordering::SeqCst);
877            if let Some(chunk) = self.chunks.next() {
878                buf[..chunk.len()].copy_from_slice(&chunk);
879                return Ok(chunk.len());
880            }
881            if let Some(release) = self.release.take() {
882                let _ = release.recv();
883            }
884            Ok(0)
885        }
886    }
887
888    impl Drop for Source {
889        fn drop(&mut self) {
890            self.dropped.store(true, Ordering::SeqCst);
891        }
892    }
893
894    fn source(chunks: Vec<Vec<u8>>, release: Option<std::sync::mpsc::Receiver<()>>) -> Source {
895        Source {
896            chunks: chunks.into_iter(),
897            release,
898            reads: Arc::default(),
899            dropped: Arc::default(),
900        }
901    }
902
903    fn files_in(dir: &Path) -> usize {
904        std::fs::read_dir(dir).unwrap().count()
905    }
906
907    #[track_caller]
908    fn wait_until(what: &str, done: impl Fn() -> bool) {
909        let deadline = Instant::now() + Duration::from_secs(5);
910        while !done() {
911            assert!(Instant::now() < deadline, "{what}");
912            std::thread::sleep(Duration::from_millis(5));
913        }
914    }
915
916    /// Many reads land in order, byte for byte, and a length given is held to.
917    #[test]
918    fn a_reader_lands_whole_in_order() {
919        let dir = tempfile::tempdir().unwrap();
920        let chunks = (0..40u8)
921            .map(|i| vec![i; 1000 + usize::from(i) * 13])
922            .collect::<Vec<_>>();
923        let whole = chunks.concat();
924        let len = whole.len() as u64;
925        let reader = source(chunks.clone(), None);
926        let file = read_to_temp(
927            Some(dir.path()),
928            Some("csv"),
929            move || Ok((reader, Some(len))),
930            &Writer::default(),
931            None,
932        )
933        .unwrap();
934        assert_eq!(std::fs::read(file.path()).unwrap(), whole);
935        assert!(file.path().to_string_lossy().ends_with(".csv"));
936
937        let reader = source(chunks, None);
938        let error = read_to_temp(
939            Some(dir.path()),
940            None,
941            move || Ok((reader, Some(len + 1))),
942            &Writer::default(),
943            None,
944        )
945        .unwrap_err();
946        assert!(matches!(error, StreamError::Short { .. }), "{error:?}");
947        drop(file);
948        assert_eq!(files_in(dir.path()), 0);
949    }
950
951    /// A download with a limit stops once more than the limit has arrived, and leaves
952    /// no file; at or under the limit it lands whole.
953    #[test]
954    fn a_download_past_its_limit_stops_and_leaves_no_file() {
955        let dir = tempfile::tempdir().unwrap();
956        let chunks = (0..8u8).map(|i| vec![i; 1000]).collect::<Vec<_>>();
957        let reader = source(chunks.clone(), None);
958        let error = read_to_temp(
959            Some(dir.path()),
960            None,
961            move || Ok((reader, None)),
962            &Writer::default(),
963            Some(7_999),
964        )
965        .unwrap_err();
966        assert!(
967            matches!(&error, StreamError::Write(report) if report.downcast_ref::<PastLimit>().is_some()),
968            "{error:?}"
969        );
970        assert_eq!(files_in(dir.path()), 0);
971
972        let reader = source(chunks, None);
973        let file = read_to_temp(
974            Some(dir.path()),
975            None,
976            move || Ok((reader, None)),
977            &Writer::default(),
978            Some(8_000),
979        )
980        .unwrap();
981        assert_eq!(std::fs::metadata(file.path()).unwrap().len(), 8_000);
982    }
983
984    /// A refused request and a read that fails each end in their error, with no file.
985    #[test]
986    fn a_refusal_or_failed_read_leaves_no_file() {
987        struct Reset(bool);
988        impl Read for Reset {
989            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
990                if std::mem::replace(&mut self.0, true) {
991                    return Err(std::io::ErrorKind::ConnectionReset.into());
992                }
993                buf[..3].copy_from_slice(b"a,b");
994                Ok(3)
995            }
996        }
997
998        let dir = tempfile::tempdir().unwrap();
999        let error = read_to_temp(
1000            Some(dir.path()),
1001            None,
1002            || Err::<(Reset, _), _>("Server returned 404 Not Found.".to_string()),
1003            &Writer::default(),
1004            None,
1005        )
1006        .unwrap_err();
1007        assert!(
1008            matches!(&error, StreamError::Open(e) if e.contains("404")),
1009            "{error:?}"
1010        );
1011        let error = read_to_temp(
1012            Some(dir.path()),
1013            None,
1014            || Ok((Reset(false), None)),
1015            &Writer::default(),
1016            None,
1017        )
1018        .unwrap_err();
1019        assert!(matches!(error, StreamError::Read(_)), "{error:?}");
1020        assert_eq!(files_in(dir.path()), 0);
1021    }
1022
1023    /// A slow disk holds the reads back: never more than the queue ahead.
1024    #[test]
1025    fn a_slow_writer_holds_the_reads_back() {
1026        let reader = source((0..64).map(|i| vec![i; 1024]).collect(), None);
1027        let reads = reader.reads.clone();
1028        let mut written = 0usize;
1029        let mut ahead = 0usize;
1030        let total = read_into(
1031            move || Ok((reader, None)),
1032            || false,
1033            |_| {
1034                std::thread::sleep(Duration::from_millis(2));
1035                written += 1;
1036                ahead = ahead.max(reads.load(Ordering::SeqCst) - written);
1037                Ok(())
1038            },
1039        )
1040        .unwrap();
1041        assert_eq!(total, 64 * 1024);
1042        // The queue, one read waiting to go into it and the one being written.
1043        assert!(ahead <= QUEUED_CHUNKS + 2, "read {ahead} chunks ahead");
1044    }
1045
1046    /// A server gone quiet mid-transfer: a stop ends the download promptly and
1047    /// removes the file, and the reading thread lets go at its next chunk.
1048    #[test]
1049    fn a_stop_while_the_server_is_silent_ends_it() {
1050        let dir = tempfile::tempdir().unwrap();
1051        let (release, held) = std::sync::mpsc::channel();
1052        let reader = source(vec![vec![1; 1024]], Some(held));
1053        let dropped = reader.dropped.clone();
1054        let stop = Arc::new(AtomicBool::new(false));
1055        let stopper = {
1056            let stop = stop.clone();
1057            let dir = dir.path().to_path_buf();
1058            std::thread::spawn(move || {
1059                // Once the first chunk is on disk, the server has gone quiet. The size
1060                // comes from the file, not its directory entry: Windows updates the
1061                // entry's only when the writer closes it.
1062                let deadline = Instant::now() + Duration::from_secs(5);
1063                let landed = loop {
1064                    let sizes = std::fs::read_dir(&dir)
1065                        .unwrap()
1066                        .filter_map(|f| std::fs::metadata(f.unwrap().path()).ok())
1067                        .map(|m| m.len());
1068                    if sizes.into_iter().any(|len| len == 1024) {
1069                        break true;
1070                    }
1071                    if Instant::now() > deadline {
1072                        break false;
1073                    }
1074                    std::thread::sleep(Duration::from_millis(5));
1075                };
1076                // Stopped either way, so a chunk that never lands fails the test
1077                // rather than leaving the read waiting on the server for good.
1078                stop.store(true, Ordering::SeqCst);
1079                (landed, Instant::now())
1080            })
1081        };
1082        let error = read_to_temp(
1083            Some(dir.path()),
1084            None,
1085            move || Ok((reader, None)),
1086            &crate::loading::unfinished::Unfinished::default().writer(stop),
1087            None,
1088        )
1089        .unwrap_err();
1090        let (landed, stopped_at) = stopper.join().unwrap();
1091        assert!(landed, "the first chunk landed");
1092        assert!(matches!(error, StreamError::Cut), "{error:?}");
1093        assert!(stopped_at.elapsed() < Duration::from_secs(2));
1094        assert_eq!(files_in(dir.path()), 0);
1095
1096        assert!(
1097            !dropped.load(Ordering::SeqCst),
1098            "still waiting on the server"
1099        );
1100        release.send(()).unwrap();
1101        wait_until("the reader was let go", || dropped.load(Ordering::SeqCst));
1102    }
1103}