Skip to main content

xet_data/processing/
file_download_session.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::io::Write;
4use std::ops::{Bound, Range, RangeBounds};
5#[cfg(not(target_family = "wasm"))]
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex};
9
10#[cfg(not(target_family = "wasm"))]
11use tokio::task::JoinHandle;
12use tracing::instrument;
13use xet_client::cas_client::Client;
14use xet_client::cas_types::FileRange;
15use xet_client::chunk_cache::ChunkCache;
16use xet_runtime::core::XetContext;
17use xet_runtime::utils::UniqueId;
18
19use super::XetFileInfo;
20use super::configurations::TranslatorConfig;
21use super::remote_client_interface::create_remote_client;
22use crate::error::{DataError, Result};
23use crate::file_reconstruction::{DownloadStream, FileReconstructor, UnorderedDownloadStream};
24use crate::progress_tracking::{GroupProgress, ItemProgressUpdater};
25
26/// Manages the downloading of files from CAS storage.
27///
28/// This struct parallels `FileUploadSession` for the download path. It holds the
29/// CAS client and a shared progress group for all downloads in the session.
30pub struct FileDownloadSession {
31    ctx: XetContext,
32    client: Arc<dyn Client>,
33    chunk_cache: Option<Arc<dyn ChunkCache>>,
34    progress: Arc<GroupProgress>,
35    active_stream_abort_callbacks: Mutex<HashMap<UniqueId, Box<dyn Fn() + Send + Sync>>>,
36    finalized: AtomicBool,
37}
38
39impl FileDownloadSession {
40    pub async fn new(config: Arc<TranslatorConfig>, chunk_cache: Option<Arc<dyn ChunkCache>>) -> Result<Arc<Self>> {
41        let session_id = config
42            .session
43            .session_id
44            .as_ref()
45            .map(Cow::Borrowed)
46            .unwrap_or_else(|| Cow::Owned(UniqueId::new().to_string()));
47
48        let ctx = config.ctx.clone();
49        let client = create_remote_client(&config, &session_id, false).await?;
50        let progress = GroupProgress::with_speed_config(
51            ctx.config.data.progress_update_speed_sampling_window,
52            ctx.config.data.progress_update_speed_min_observations,
53        );
54
55        Ok(Arc::new(Self {
56            ctx,
57            client,
58            chunk_cache,
59            progress,
60            active_stream_abort_callbacks: Mutex::new(HashMap::new()),
61            finalized: AtomicBool::new(false),
62        }))
63    }
64
65    /// Construct a download session from an existing CAS client.
66    ///
67    /// This path uses default progress speed settings. Use [`Self::new`] when the
68    /// session should inherit the configured speed parameters from the context used
69    /// to build [`TranslatorConfig`].
70    pub fn from_client(
71        ctx: &XetContext,
72        client: Arc<dyn Client>,
73        chunk_cache: Option<Arc<dyn ChunkCache>>,
74    ) -> Arc<Self> {
75        let progress = GroupProgress::new();
76        Arc::new(Self {
77            ctx: ctx.clone(),
78            client,
79            chunk_cache,
80            progress,
81            active_stream_abort_callbacks: Mutex::new(HashMap::new()),
82            finalized: AtomicBool::new(false),
83        })
84    }
85
86    pub fn report(&self) -> crate::progress_tracking::GroupProgressReport {
87        self.progress.report()
88    }
89
90    pub fn item_report(&self, id: UniqueId) -> Option<crate::progress_tracking::ItemProgressReport> {
91        self.progress.item_report(id)
92    }
93
94    pub fn item_reports(&self) -> HashMap<UniqueId, crate::progress_tracking::ItemProgressReport> {
95        self.progress.item_reports()
96    }
97
98    fn register_stream_abort_callback(&self, id: UniqueId, callback: Box<dyn Fn() + Send + Sync>) {
99        self.active_stream_abort_callbacks.lock().unwrap().insert(id, callback);
100    }
101
102    pub fn unregister_stream_abort_callback(&self, id: UniqueId) {
103        self.active_stream_abort_callbacks.lock().unwrap().remove(&id);
104    }
105
106    pub fn abort_active_streams(&self) {
107        let callbacks = self.active_stream_abort_callbacks.lock().unwrap();
108        for callback in callbacks.values() {
109            callback();
110        }
111    }
112
113    /// Creates a streaming download of a file, optionally restricted to a
114    /// byte range.
115    ///
116    /// Returns a [`DownloadStream`] that yields data chunks as the file is
117    /// reconstructed. Reconstruction starts lazily on first
118    /// [`DownloadStream::next`] / [`DownloadStream::blocking_next`] call
119    /// (or when `start()` is called explicitly).
120    ///
121    /// If `source_range` is `Some`, only the specified byte range of the
122    /// file is reconstructed.
123    ///
124    /// This path does not acquire the session-level file download semaphore.
125    #[instrument(skip_all, name = "FileDownloadSession::download_stream", fields(hash = file_info.hash()))]
126    pub async fn download_stream(
127        &self,
128        file_info: &XetFileInfo,
129        source_range: Option<Range<u64>>,
130    ) -> Result<(UniqueId, DownloadStream)> {
131        self.check_not_finalized()?;
132        let id = UniqueId::new();
133        let progress_updater = self.progress.new_item(id, "stream");
134        let range = source_range.map(|r| FileRange::new(r.start, r.end));
135        let reconstructor = self.setup_reconstructor(file_info, range, Some(progress_updater))?;
136        let stream = reconstructor.reconstruct_to_stream();
137        self.register_stream_abort_callback(id, stream.abort_callback());
138        Ok((id, stream))
139    }
140
141    /// Creates an unordered streaming download of a file, optionally
142    /// restricted to a byte range.
143    ///
144    /// Returns an [`UnorderedDownloadStream`] that yields `(offset, Bytes)`
145    /// chunks in whatever order they complete. The total expected size is
146    /// set from the range length (or `file_info.file_size()` when no range
147    /// is given).
148    ///
149    /// If `source_range` is `Some`, only the specified byte range of the
150    /// file is reconstructed.
151    ///
152    /// This path does not acquire the session-level file download semaphore.
153    #[instrument(skip_all, name = "FileDownloadSession::download_unordered_stream", fields(hash = file_info.hash()))]
154    pub async fn download_unordered_stream(
155        &self,
156        file_info: &XetFileInfo,
157        source_range: Option<Range<u64>>,
158    ) -> Result<(UniqueId, UnorderedDownloadStream)> {
159        self.check_not_finalized()?;
160        let id = UniqueId::new();
161        let progress_updater = self.progress.new_item(id, "unordered_stream");
162        let range = source_range.map(|r| FileRange::new(r.start, r.end));
163        let reconstructor = self.setup_reconstructor(file_info, range, Some(progress_updater))?;
164        let stream = reconstructor.reconstruct_to_unordered_stream();
165        self.register_stream_abort_callback(id, stream.abort_callback());
166        Ok((id, stream))
167    }
168
169    /// Creates a streaming download of a byte range of a file.
170    ///
171    /// Accepts any `RangeBounds<u64>`: `4..12`, `5..`, `..100`, or `..` (full file).
172    ///
173    /// This path does not acquire the session-level file download semaphore.
174    #[instrument(skip_all, name = "FileDownloadSession::download_stream_range", fields(hash = file_info.hash()))]
175    pub async fn download_stream_range(
176        &self,
177        file_info: &XetFileInfo,
178        range: impl RangeBounds<u64>,
179    ) -> Result<(UniqueId, DownloadStream)> {
180        self.check_not_finalized()?;
181        let file_range = range_bounds_to_file_range(&range)?;
182        let id = UniqueId::new();
183        let progress_updater = self.progress.new_item(id, "stream");
184        let reconstructor = self.setup_reconstructor(file_info, file_range, Some(progress_updater))?;
185        let stream = reconstructor.reconstruct_to_stream();
186        self.register_stream_abort_callback(id, stream.abort_callback());
187        Ok((id, stream))
188    }
189    fn check_not_finalized(&self) -> Result<()> {
190        if self.finalized.load(Ordering::Acquire) {
191            return Err(DataError::InvalidOperation("FileDownloadSession already finalized".to_string()));
192        }
193        Ok(())
194    }
195
196    /// Finalizes the session; in debug builds, asserts all items are complete.
197    pub async fn finalize(&self) -> Result<()> {
198        if self.finalized.swap(true, Ordering::AcqRel) {
199            return Err(DataError::InvalidOperation("FileDownloadSession already finalized".to_string()));
200        }
201        #[cfg(debug_assertions)]
202        self.progress.assert_complete();
203        Ok(())
204    }
205
206    fn setup_reconstructor(
207        &self,
208        file_info: &XetFileInfo,
209        range: Option<FileRange>,
210        progress_updater: Option<Arc<ItemProgressUpdater>>,
211    ) -> Result<FileReconstructor> {
212        let file_id = file_info.merkle_hash()?;
213
214        let mut reconstructor = FileReconstructor::new(&self.ctx, &self.client, file_id);
215
216        match range {
217            Some(range) if range.end < u64::MAX => {
218                // Fully bounded range: we know the exact download size upfront.
219                let size = range.end - range.start;
220                if let Some(ref updater) = progress_updater {
221                    updater.update_item_size(size, true);
222                }
223                reconstructor = reconstructor.with_byte_range(range);
224            },
225            Some(range) => {
226                // Open-ended range (end == u64::MAX): pass the range to set the
227                // start position, but let ReconstructionTermManager discover
228                // the actual end and finalize progress incrementally.
229                reconstructor = reconstructor.with_byte_range(range);
230            },
231            None if file_info.file_size().is_some() => {
232                // Full file with caller-provided size. Set progress upfront so
233                // UI consumers get percentage-based progress. SizeMismatch is
234                // validated after reconstruction in download_file_with_id.
235                if let Some(ref updater) = progress_updater {
236                    updater.update_item_size(file_info.file_size().unwrap(), true);
237                }
238            },
239            None => {
240                // Full file with unknown size: the reconstructor uses
241                // FileRange::full() internally and ReconstructionTermManager
242                // discovers the size incrementally.
243            },
244        }
245
246        if let Some(updater) = progress_updater {
247            reconstructor = reconstructor.with_progress_updater(updater);
248        }
249
250        if let Some(ref cache) = self.chunk_cache {
251            reconstructor = reconstructor.with_chunk_cache(cache.clone());
252        }
253
254        Ok(reconstructor)
255    }
256}
257
258// Native filesystem download methods — require std::path / std::io::Write /
259// tokio::JoinHandle and have no wasm equivalent.
260#[cfg(not(target_family = "wasm"))]
261impl FileDownloadSession {
262    /// Spawns a download task that writes `file_info` to `write_path`.
263    ///
264    /// Acquires a permit from the global download semaphore before starting.
265    /// Returns the tracking ID and the join handle for the spawned task.
266    pub async fn download_file_background(
267        self: &Arc<Self>,
268        file_info: XetFileInfo,
269        write_path: PathBuf,
270    ) -> Result<(UniqueId, JoinHandle<Result<u64>>)> {
271        self.check_not_finalized()?;
272        let id = UniqueId::new();
273        let session = self.clone();
274        let runtime = self.ctx.runtime.clone();
275        let semaphore = self.ctx.common.file_download_semaphore.clone();
276        let handle = runtime.spawn(async move {
277            let _permit = semaphore.acquire().await?;
278            session.download_file_with_id(&file_info, &write_path, id).await
279        });
280        Ok((id, handle))
281    }
282
283    /// Downloads a complete file to the given path.
284    #[instrument(skip_all, name = "FileDownloadSession::download_file", fields(hash = file_info.hash()))]
285    pub async fn download_file(&self, file_info: &XetFileInfo, write_path: &Path) -> Result<(UniqueId, u64)> {
286        self.check_not_finalized()?;
287        let id = UniqueId::new();
288        let n_bytes = self.download_file_with_id(file_info, write_path, id).await?;
289        Ok((id, n_bytes))
290    }
291
292    async fn download_file_with_id(&self, file_info: &XetFileInfo, write_path: &Path, id: UniqueId) -> Result<u64> {
293        let name = Arc::from(write_path.to_string_lossy().as_ref());
294        let progress_updater = self.progress.new_item(id, name);
295        let reconstructor = self.setup_reconstructor(file_info, None, Some(progress_updater))?;
296        let n_bytes = reconstructor.reconstruct_to_file(write_path, None, true).await?;
297        // Caller is responsible for cleaning up the file on error (consistent
298        // with other error paths); see download_group.rs error handling.
299        if let Some(expected_size) = file_info.file_size()
300            && n_bytes != expected_size
301        {
302            return Err(DataError::SizeMismatch {
303                expected: expected_size,
304                actual: n_bytes,
305            });
306        }
307        Ok(n_bytes)
308    }
309}
310
311// Writer-sink download — available on all targets. Unlike the filesystem
312// methods above, this writes to a caller-provided `Write` sink and needs no
313// `std::path`. On native the sink is driven by a background writer thread; on
314// wasm the streaming reconstruction path feeds it inline.
315impl FileDownloadSession {
316    /// Downloads a byte range of a file and writes it to the provided writer.
317    ///
318    /// The provided `source_range` is interpreted against the original file; output
319    /// starts at the writer's current position. Accepts any `RangeBounds<u64>`:
320    /// `4..12`, `5..`, `..100`, or `..` (full file).
321    ///
322    /// This path does not acquire the session-level file download semaphore.
323    #[instrument(skip_all, name = "FileDownloadSession::download_to_writer",
324        fields(hash = file_info.hash(), range_start = tracing::field::Empty, range_end = tracing::field::Empty))]
325    pub async fn download_to_writer<W: Write + Send + 'static>(
326        &self,
327        file_info: &XetFileInfo,
328        source_range: impl RangeBounds<u64>,
329        writer: W,
330    ) -> Result<(UniqueId, u64)> {
331        self.check_not_finalized()?;
332        let range = range_bounds_to_file_range(&source_range)?;
333        if let Some(ref r) = range {
334            let span = tracing::Span::current();
335            span.record("range_start", r.start);
336            span.record("range_end", r.end);
337        }
338        let id = UniqueId::new();
339        let name = Arc::from("");
340        let progress_updater = self.progress.new_item(id, name);
341        let reconstructor = self.setup_reconstructor(file_info, range, Some(progress_updater))?;
342        let n_bytes = reconstructor.reconstruct_to_writer(writer).await?;
343
344        let expected_size = match range {
345            Some(r) if r.end < u64::MAX => Some(r.end - r.start),
346            None => file_info.file_size(),
347            _ => None,
348        };
349        if let Some(expected) = expected_size
350            && n_bytes != expected
351        {
352            return Err(DataError::SizeMismatch {
353                expected,
354                actual: n_bytes,
355            });
356        }
357
358        Ok((id, n_bytes))
359    }
360}
361
362/// Converts any `RangeBounds<u64>` into an `Option<FileRange>`.
363///
364/// Returns `None` for the unbounded range `..` (equivalent to full file),
365/// and `Some(FileRange)` otherwise. Open-ended ranges use `u64::MAX` as
366/// the end sentinel (matching `FileRange::full()`).
367///
368/// Returns an error for inverted ranges where `start > end`.
369fn range_bounds_to_file_range(range: &impl RangeBounds<u64>) -> Result<Option<FileRange>> {
370    let start = match range.start_bound() {
371        Bound::Included(&s) => s,
372        Bound::Excluded(&s) => s.saturating_add(1),
373        Bound::Unbounded => 0,
374    };
375    let end = match range.end_bound() {
376        Bound::Included(&e) => e.saturating_add(1),
377        Bound::Excluded(&e) => e,
378        Bound::Unbounded => u64::MAX,
379    };
380    if start > end {
381        return Err(DataError::InvalidOperation(format!("Invalid range: start ({start}) > end ({end})")));
382    }
383    if start == 0 && end == u64::MAX {
384        Ok(None)
385    } else {
386        Ok(Some(FileRange::new(start, end)))
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use std::fs::{read, write};
393    use std::io::{Seek, SeekFrom};
394    use std::sync::{Arc, OnceLock};
395
396    use tempfile::tempdir;
397    use xet_runtime::core::XetContext;
398
399    use super::*;
400    use crate::processing::configurations::TranslatorConfig;
401    use crate::processing::file_cleaner::Sha256Policy;
402    use crate::processing::{FileUploadSession, XetFileInfo};
403
404    fn get_runtime() -> Arc<xet_runtime::core::XetRuntime> {
405        static THREADPOOL: OnceLock<Arc<xet_runtime::core::XetRuntime>> = OnceLock::new();
406        THREADPOOL
407            .get_or_init(|| {
408                XetContext::default()
409                    .expect("Error starting multithreaded runtime.")
410                    .runtime
411                    .clone()
412            })
413            .clone()
414    }
415
416    async fn upload_data(cas_path: &Path, data: &[u8]) -> XetFileInfo {
417        let ctx = XetContext::default().unwrap();
418        let upload_session = FileUploadSession::new(TranslatorConfig::local_config(&ctx, cas_path).unwrap().into())
419            .await
420            .unwrap();
421
422        let (_id, mut cleaner) = upload_session
423            .start_clean(Some("test".into()), Some(data.len() as u64), Sha256Policy::Compute)
424            .unwrap();
425        cleaner.add_data(data).await.unwrap();
426        let (xfi, _metrics) = cleaner.finish().await.unwrap();
427        upload_session.finalize().await.unwrap();
428        xfi
429    }
430
431    #[test]
432    fn test_download_file() {
433        let runtime = get_runtime();
434        runtime
435            .bridge_sync(async {
436                let temp = tempdir().unwrap();
437                let cas_path = temp.path().join("cas");
438                let original_data = b"Hello, download session!";
439
440                let xfi = upload_data(&cas_path, original_data).await;
441
442                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
443                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
444
445                let out_path = temp.path().join("output.txt");
446                let (_id, n_bytes) = session.download_file(&xfi, &out_path).await.unwrap();
447
448                assert_eq!(n_bytes, original_data.len() as u64);
449                assert_eq!(read(&out_path).unwrap(), original_data);
450            })
451            .unwrap();
452    }
453
454    #[test]
455    fn test_download_file_creates_parent_dirs() {
456        let runtime = get_runtime();
457        runtime
458            .bridge_sync(async {
459                let temp = tempdir().unwrap();
460                let cas_path = temp.path().join("cas");
461                let original_data = b"nested directory test";
462
463                let xfi = upload_data(&cas_path, original_data).await;
464
465                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
466                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
467
468                let out_path = temp.path().join("deep").join("nested").join("dir").join("output.txt");
469                assert!(!out_path.parent().unwrap().exists());
470
471                session.download_file(&xfi, &out_path).await.unwrap();
472
473                assert_eq!(read(&out_path).unwrap(), original_data);
474            })
475            .unwrap();
476    }
477
478    #[test]
479    fn test_download_to_writer() {
480        let runtime = get_runtime();
481        runtime
482            .bridge_sync(async {
483                let temp = tempdir().unwrap();
484                let cas_path = temp.path().join("cas");
485                let original_data = b"0123456789abcdef";
486
487                let xfi = upload_data(&cas_path, original_data).await;
488
489                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
490                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
491
492                let out_path = temp.path().join("partial_writer.txt");
493                write(&out_path, vec![0u8; original_data.len()]).unwrap();
494
495                let mut file = std::fs::OpenOptions::new().write(true).open(&out_path).unwrap();
496                file.seek(SeekFrom::Start(4)).unwrap();
497
498                let (_id, n_bytes) = session.download_to_writer(&xfi, 4..12, file).await.unwrap();
499
500                assert_eq!(n_bytes, 8);
501                let result = read(&out_path).unwrap();
502                assert_eq!(&result[4..12], &original_data[4..12]);
503            })
504            .unwrap();
505    }
506
507    #[test]
508    fn test_download_to_writer_parallel_partitioned_file() {
509        let runtime = get_runtime();
510        runtime
511            .bridge_sync(async {
512                let temp = tempdir().unwrap();
513                let cas_path = temp.path().join("cas");
514                let original_data = b"abcdefghijklmnopqrstuvwxyz0123456789";
515
516                let xfi = upload_data(&cas_path, original_data).await;
517                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
518                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
519
520                let out_path = temp.path().join("partitioned.txt");
521                write(&out_path, vec![0u8; original_data.len()]).unwrap();
522
523                let n_parts = 5u64;
524                let total = original_data.len() as u64;
525                let mut tasks = Vec::new();
526
527                for idx in 0..n_parts {
528                    let start = (idx * total) / n_parts;
529                    let end = ((idx + 1) * total) / n_parts;
530                    if start == end {
531                        continue;
532                    }
533
534                    let session = session.clone();
535                    let xfi = xfi.clone();
536                    let out_path = out_path.clone();
537                    tasks.push(tokio::spawn(async move {
538                        let mut writer = std::fs::OpenOptions::new().write(true).open(out_path).unwrap();
539                        writer.seek(SeekFrom::Start(start)).unwrap();
540                        session.download_to_writer(&xfi, start..end, writer).await
541                    }));
542                }
543
544                for task in tasks {
545                    task.await.unwrap().unwrap();
546                }
547
548                let result = read(&out_path).unwrap();
549                assert_eq!(result, original_data);
550            })
551            .unwrap();
552    }
553
554    #[test]
555    fn test_download_multiple_files_concurrent() {
556        let runtime = get_runtime();
557        runtime
558            .bridge_sync(async {
559                let temp = tempdir().unwrap();
560                let cas_path = temp.path().join("cas");
561
562                let data_a = b"File A content for concurrent test";
563                let data_b = b"File B content for concurrent test - different";
564
565                let xfi_a = upload_data(&cas_path, data_a).await;
566                let xfi_b = upload_data(&cas_path, data_b).await;
567
568                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
569                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
570
571                let out_a = temp.path().join("out_a.txt");
572                let out_b = temp.path().join("out_b.txt");
573
574                let session_a = session.clone();
575                let xfi_a_clone = xfi_a.clone();
576                let out_a_clone = out_a.clone();
577                let task_a = tokio::spawn(async move { session_a.download_file(&xfi_a_clone, &out_a_clone).await });
578
579                let session_b = session.clone();
580                let xfi_b_clone = xfi_b.clone();
581                let out_b_clone = out_b.clone();
582                let task_b = tokio::spawn(async move { session_b.download_file(&xfi_b_clone, &out_b_clone).await });
583
584                task_a.await.unwrap().unwrap();
585                task_b.await.unwrap().unwrap();
586
587                assert_eq!(read(&out_a).unwrap(), data_a);
588                assert_eq!(read(&out_b).unwrap(), data_b);
589            })
590            .unwrap();
591    }
592
593    // ==================== Download Stream Tests ====================
594
595    #[test]
596    fn test_download_stream_async() {
597        let runtime = get_runtime();
598        runtime
599            .bridge_sync(async {
600                let temp = tempdir().unwrap();
601                let cas_path = temp.path().join("cas");
602                let original_data = b"Hello, streaming download!";
603
604                let xfi = upload_data(&cas_path, original_data).await;
605
606                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
607                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
608
609                let (_id, mut stream) = session.download_stream(&xfi, None).await.unwrap();
610
611                let mut collected = Vec::new();
612                while let Some(chunk) = stream.next().await.unwrap() {
613                    collected.extend_from_slice(&chunk);
614                }
615
616                assert_eq!(collected, original_data);
617            })
618            .unwrap();
619    }
620
621    #[test]
622    fn test_download_stream_blocking() {
623        let runtime = get_runtime();
624        runtime
625            .bridge_sync(async {
626                let temp = tempdir().unwrap();
627                let cas_path = temp.path().join("cas");
628                let original_data = b"Blocking stream test data";
629
630                let xfi = upload_data(&cas_path, original_data).await;
631
632                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
633                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
634
635                let (_id, stream) = session.download_stream(&xfi, None).await.unwrap();
636
637                let collected = tokio::task::spawn_blocking(move || {
638                    let mut stream = stream;
639                    let mut buf = Vec::new();
640                    while let Some(chunk) = stream.blocking_next().unwrap() {
641                        buf.extend_from_slice(&chunk);
642                    }
643                    buf
644                })
645                .await
646                .unwrap();
647
648                assert_eq!(collected, original_data);
649            })
650            .unwrap();
651    }
652
653    #[test]
654    fn test_download_stream_returns_none_after_finish() {
655        let runtime = get_runtime();
656        runtime
657            .bridge_sync(async {
658                let temp = tempdir().unwrap();
659                let cas_path = temp.path().join("cas");
660                let original_data = b"Extra none calls";
661
662                let xfi = upload_data(&cas_path, original_data).await;
663
664                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
665                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
666
667                let (_id, mut stream) = session.download_stream(&xfi, None).await.unwrap();
668
669                while stream.next().await.unwrap().is_some() {}
670
671                // Subsequent calls should return Ok(None)
672                assert!(stream.next().await.unwrap().is_none());
673                assert!(stream.next().await.unwrap().is_none());
674            })
675            .unwrap();
676    }
677
678    #[test]
679    fn test_download_stream_multiple_concurrent() {
680        let runtime = get_runtime();
681        runtime
682            .bridge_sync(async {
683                let temp = tempdir().unwrap();
684                let cas_path = temp.path().join("cas");
685
686                let data_a = b"Stream A for concurrent download";
687                let data_b = b"Stream B for concurrent download - different";
688
689                let xfi_a = upload_data(&cas_path, data_a).await;
690                let xfi_b = upload_data(&cas_path, data_b).await;
691
692                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
693                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
694
695                let (_id_a, mut stream_a) = session.download_stream(&xfi_a, None).await.unwrap();
696                let (_id_b, mut stream_b) = session.download_stream(&xfi_b, None).await.unwrap();
697
698                let task_a = tokio::spawn(async move {
699                    let mut buf = Vec::new();
700                    while let Some(chunk) = stream_a.next().await.unwrap() {
701                        buf.extend_from_slice(&chunk);
702                    }
703                    buf
704                });
705
706                let task_b = tokio::spawn(async move {
707                    let mut buf = Vec::new();
708                    while let Some(chunk) = stream_b.next().await.unwrap() {
709                        buf.extend_from_slice(&chunk);
710                    }
711                    buf
712                });
713
714                let result_a = task_a.await.unwrap();
715                let result_b = task_b.await.unwrap();
716
717                assert_eq!(result_a, data_a);
718                assert_eq!(result_b, data_b);
719            })
720            .unwrap();
721    }
722
723    #[test]
724    fn test_drop_stream_without_reading() {
725        let runtime = get_runtime();
726        runtime
727            .bridge_sync(async {
728                let temp = tempdir().unwrap();
729                let cas_path = temp.path().join("cas");
730                let original_data = b"Drop-without-reading cleanup test";
731
732                let xfi = upload_data(&cas_path, original_data).await;
733
734                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
735                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
736
737                let (_id, stream) = session.download_stream(&xfi, None).await.unwrap();
738                drop(stream);
739                tokio::task::yield_now().await;
740
741                let out_path = temp.path().join("after_drop.txt");
742                session.download_file(&xfi, &out_path).await.unwrap();
743                assert_eq!(read(&out_path).unwrap(), original_data);
744            })
745            .unwrap();
746    }
747
748    #[test]
749    fn test_drop_stream_multiple_cycles_then_download() {
750        let runtime = get_runtime();
751        runtime
752            .bridge_sync(async {
753                let temp = tempdir().unwrap();
754                let cas_path = temp.path().join("cas");
755                let original_data = b"Multi-cycle drop cleanup test";
756
757                let xfi = upload_data(&cas_path, original_data).await;
758
759                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
760                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
761
762                for i in 0..5u32 {
763                    let (_id, mut stream) = session.download_stream(&xfi, None).await.unwrap();
764                    if i % 3 == 0 {
765                        let _ = stream.next().await;
766                    }
767                    drop(stream);
768                    tokio::task::yield_now().await;
769                }
770
771                let out_path = temp.path().join("after_cycles.txt");
772                session.download_file(&xfi, &out_path).await.unwrap();
773                assert_eq!(read(&out_path).unwrap(), original_data);
774            })
775            .unwrap();
776    }
777
778    #[test]
779    fn test_drop_stream_blocking_mid_read_then_download() {
780        let runtime = get_runtime();
781        runtime
782            .bridge_sync(async {
783                let temp = tempdir().unwrap();
784                let cas_path = temp.path().join("cas");
785                let original_data = b"Blocking drop cleanup test data";
786
787                let xfi = upload_data(&cas_path, original_data).await;
788
789                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
790                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
791
792                let (_id, stream) = session.download_stream(&xfi, None).await.unwrap();
793
794                tokio::task::spawn_blocking(move || {
795                    let mut stream = stream;
796                    let _chunk = stream.blocking_next().unwrap();
797                })
798                .await
799                .unwrap();
800
801                tokio::task::yield_now().await;
802
803                let out_path = temp.path().join("after_blocking_drop.txt");
804                session.download_file(&xfi, &out_path).await.unwrap();
805                assert_eq!(read(&out_path).unwrap(), original_data);
806            })
807            .unwrap();
808    }
809
810    #[test]
811    fn test_cancel_stream_before_start_returns_none() {
812        let runtime = get_runtime();
813        runtime
814            .bridge_sync(async {
815                let temp = tempdir().unwrap();
816                let cas_path = temp.path().join("cas");
817                let original_data = b"Cancel-before-start stream test";
818
819                let xfi = upload_data(&cas_path, original_data).await;
820
821                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
822                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
823
824                let (_id, mut stream) = session.download_stream(&xfi, None).await.unwrap();
825                stream.cancel();
826                assert!(stream.next().await.unwrap().is_none());
827                assert!(stream.next().await.unwrap().is_none());
828            })
829            .unwrap();
830    }
831
832    #[test]
833    fn test_cancel_stream_after_first_chunk_returns_none() {
834        let runtime = get_runtime();
835        runtime
836            .bridge_sync(async {
837                let temp = tempdir().unwrap();
838                let cas_path = temp.path().join("cas");
839                let original_data = b"Cancel-after-first-chunk stream test data";
840
841                let xfi = upload_data(&cas_path, original_data).await;
842
843                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
844                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
845
846                let (_id, mut stream) = session.download_stream(&xfi, None).await.unwrap();
847                let _ = stream.next().await.unwrap();
848                stream.cancel();
849                assert!(stream.next().await.unwrap().is_none());
850                assert!(stream.next().await.unwrap().is_none());
851
852                let out_path = temp.path().join("after_cancel.txt");
853                session.download_file(&xfi, &out_path).await.unwrap();
854                assert_eq!(read(&out_path).unwrap(), original_data);
855            })
856            .unwrap();
857    }
858
859    // ==================== Range Download Tests ====================
860
861    #[test]
862    fn test_download_to_writer_range_from() {
863        let runtime = get_runtime();
864        runtime
865            .clone()
866            .external_run_async_task(async {
867                let temp = tempdir().unwrap();
868                let cas_path = temp.path().join("cas");
869                let original_data = b"0123456789abcdef";
870
871                let xfi = upload_data(&cas_path, original_data).await;
872
873                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
874                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
875
876                let out_path = temp.path().join("range_from.bin");
877                let file = std::fs::File::create(&out_path).unwrap();
878                let (_id, n_bytes) = session.download_to_writer(&xfi, 4.., file).await.unwrap();
879
880                assert_eq!(n_bytes, 12);
881                assert_eq!(read(&out_path).unwrap(), &original_data[4..]);
882            })
883            .unwrap();
884    }
885
886    #[test]
887    fn test_download_to_writer_range_to() {
888        let runtime = get_runtime();
889        runtime
890            .clone()
891            .external_run_async_task(async {
892                let temp = tempdir().unwrap();
893                let cas_path = temp.path().join("cas");
894                let original_data = b"0123456789abcdef";
895
896                let xfi = upload_data(&cas_path, original_data).await;
897
898                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
899                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
900
901                let out_path = temp.path().join("range_to.bin");
902                let file = std::fs::File::create(&out_path).unwrap();
903                let (_id, n_bytes) = session.download_to_writer(&xfi, ..8, file).await.unwrap();
904
905                assert_eq!(n_bytes, 8);
906                assert_eq!(read(&out_path).unwrap(), &original_data[..8]);
907            })
908            .unwrap();
909    }
910
911    #[test]
912    fn test_download_to_writer_full_range() {
913        let runtime = get_runtime();
914        runtime
915            .clone()
916            .external_run_async_task(async {
917                let temp = tempdir().unwrap();
918                let cas_path = temp.path().join("cas");
919                let original_data = b"0123456789abcdef";
920
921                let xfi = upload_data(&cas_path, original_data).await;
922
923                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
924                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
925
926                let out_path = temp.path().join("full_range.bin");
927                let file = std::fs::File::create(&out_path).unwrap();
928                let (_id, n_bytes) = session.download_to_writer(&xfi, .., file).await.unwrap();
929
930                assert_eq!(n_bytes, original_data.len() as u64);
931                assert_eq!(read(&out_path).unwrap(), original_data);
932            })
933            .unwrap();
934    }
935
936    #[test]
937    fn test_download_to_writer_range_inclusive() {
938        let runtime = get_runtime();
939        runtime
940            .clone()
941            .external_run_async_task(async {
942                let temp = tempdir().unwrap();
943                let cas_path = temp.path().join("cas");
944                let original_data = b"0123456789abcdef";
945
946                let xfi = upload_data(&cas_path, original_data).await;
947
948                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
949                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
950
951                let out_path = temp.path().join("range_incl.bin");
952                let file = std::fs::File::create(&out_path).unwrap();
953                let (_id, n_bytes) = session.download_to_writer(&xfi, 2..=5, file).await.unwrap();
954
955                assert_eq!(n_bytes, 4);
956                assert_eq!(read(&out_path).unwrap(), &original_data[2..=5]);
957            })
958            .unwrap();
959    }
960
961    // ==================== Range Stream Tests ====================
962
963    #[test]
964    fn test_download_stream_range_bounded() {
965        let runtime = get_runtime();
966        runtime
967            .clone()
968            .external_run_async_task(async {
969                let temp = tempdir().unwrap();
970                let cas_path = temp.path().join("cas");
971                let original_data = b"0123456789abcdef";
972
973                let xfi = upload_data(&cas_path, original_data).await;
974
975                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
976                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
977
978                let (_id, mut stream) = session.download_stream_range(&xfi, 4..12).await.unwrap();
979
980                let mut collected = Vec::new();
981                while let Some(chunk) = stream.next().await.unwrap() {
982                    collected.extend_from_slice(&chunk);
983                }
984
985                assert_eq!(collected, &original_data[4..12]);
986            })
987            .unwrap();
988    }
989
990    #[test]
991    fn test_download_stream_range_from() {
992        let runtime = get_runtime();
993        runtime
994            .clone()
995            .external_run_async_task(async {
996                let temp = tempdir().unwrap();
997                let cas_path = temp.path().join("cas");
998                let original_data = b"0123456789abcdef";
999
1000                let xfi = upload_data(&cas_path, original_data).await;
1001
1002                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1003                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1004
1005                let (_id, mut stream) = session.download_stream_range(&xfi, 10..).await.unwrap();
1006
1007                let mut collected = Vec::new();
1008                while let Some(chunk) = stream.next().await.unwrap() {
1009                    collected.extend_from_slice(&chunk);
1010                }
1011
1012                assert_eq!(collected, &original_data[10..]);
1013            })
1014            .unwrap();
1015    }
1016
1017    #[test]
1018    fn test_download_stream_range_to() {
1019        let runtime = get_runtime();
1020        runtime
1021            .clone()
1022            .external_run_async_task(async {
1023                let temp = tempdir().unwrap();
1024                let cas_path = temp.path().join("cas");
1025                let original_data = b"0123456789abcdef";
1026
1027                let xfi = upload_data(&cas_path, original_data).await;
1028
1029                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1030                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1031
1032                let (_id, mut stream) = session.download_stream_range(&xfi, ..6).await.unwrap();
1033
1034                let mut collected = Vec::new();
1035                while let Some(chunk) = stream.next().await.unwrap() {
1036                    collected.extend_from_slice(&chunk);
1037                }
1038
1039                assert_eq!(collected, &original_data[..6]);
1040            })
1041            .unwrap();
1042    }
1043
1044    // ==================== Download with unknown file size ====================
1045
1046    #[test]
1047    fn test_download_file_unknown_size() {
1048        let runtime = get_runtime();
1049        runtime
1050            .clone()
1051            .external_run_async_task(async {
1052                let temp = tempdir().unwrap();
1053                let cas_path = temp.path().join("cas");
1054                let original_data = b"File with unknown size test";
1055
1056                let xfi = upload_data(&cas_path, original_data).await;
1057                let xfi_no_size = XetFileInfo::new_hash_only(xfi.hash().to_string());
1058
1059                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1060                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1061
1062                let out_path = temp.path().join("output_unknown.txt");
1063                let (_id, n_bytes) = session.download_file(&xfi_no_size, &out_path).await.unwrap();
1064
1065                assert_eq!(n_bytes, original_data.len() as u64);
1066                assert_eq!(read(&out_path).unwrap(), original_data);
1067            })
1068            .unwrap();
1069    }
1070
1071    #[test]
1072    fn test_download_stream_unknown_size() {
1073        let runtime = get_runtime();
1074        runtime
1075            .clone()
1076            .external_run_async_task(async {
1077                let temp = tempdir().unwrap();
1078                let cas_path = temp.path().join("cas");
1079                let original_data = b"Stream with unknown size test";
1080
1081                let xfi = upload_data(&cas_path, original_data).await;
1082                let xfi_no_size = XetFileInfo::new_hash_only(xfi.hash().to_string());
1083
1084                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1085                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1086
1087                let (_id, mut stream) = session.download_stream(&xfi_no_size, None).await.unwrap();
1088
1089                let mut collected = Vec::new();
1090                while let Some(chunk) = stream.next().await.unwrap() {
1091                    collected.extend_from_slice(&chunk);
1092                }
1093
1094                assert_eq!(collected, original_data);
1095            })
1096            .unwrap();
1097    }
1098
1099    #[cfg(not(debug_assertions))]
1100    #[test]
1101    fn test_download_file_size_mismatch_error() {
1102        let runtime = get_runtime();
1103        runtime
1104            .clone()
1105            .external_run_async_task(async {
1106                let temp = tempdir().unwrap();
1107                let cas_path = temp.path().join("cas");
1108                let original_data = b"Size mismatch test data";
1109
1110                let xfi = upload_data(&cas_path, original_data).await;
1111                let wrong_size_xfi = XetFileInfo::new(xfi.hash().to_string(), 999);
1112
1113                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1114                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1115
1116                let out_path = temp.path().join("output_mismatch.txt");
1117                let err = session.download_file(&wrong_size_xfi, &out_path).await.unwrap_err();
1118
1119                assert!(
1120                    matches!(err, DataError::SizeMismatch { expected: 999, .. }),
1121                    "Expected SizeMismatch error, got: {err:?}"
1122                );
1123            })
1124            .unwrap();
1125    }
1126
1127    // ==================== range_bounds_to_file_range unit tests ====================
1128
1129    #[test]
1130    fn test_range_bounds_conversion() {
1131        use super::range_bounds_to_file_range;
1132
1133        assert_eq!(range_bounds_to_file_range(&(..)).unwrap(), None);
1134        assert_eq!(range_bounds_to_file_range(&(0..100)).unwrap(), Some(FileRange::new(0, 100)));
1135        assert_eq!(range_bounds_to_file_range(&(5..)).unwrap(), Some(FileRange::new(5, u64::MAX)));
1136        assert_eq!(range_bounds_to_file_range(&(..50)).unwrap(), Some(FileRange::new(0, 50)));
1137        assert_eq!(range_bounds_to_file_range(&(10..=19)).unwrap(), Some(FileRange::new(10, 20)));
1138    }
1139
1140    #[test]
1141    fn test_range_bounds_inverted_range_errors() {
1142        use super::range_bounds_to_file_range;
1143
1144        let result = range_bounds_to_file_range(&(10..5));
1145        assert!(result.is_err());
1146    }
1147
1148    #[test]
1149    fn test_download_to_writer_empty_range() {
1150        let runtime = get_runtime();
1151        runtime
1152            .clone()
1153            .external_run_async_task(async {
1154                let temp = tempdir().unwrap();
1155                let cas_path = temp.path().join("cas");
1156                let original_data = b"0123456789abcdef";
1157
1158                let xfi = upload_data(&cas_path, original_data).await;
1159
1160                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1161                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1162
1163                let out_path = temp.path().join("empty_range.bin");
1164                let file = std::fs::File::create(&out_path).unwrap();
1165                let (_id, n_bytes) = session.download_to_writer(&xfi, 5..5, file).await.unwrap();
1166
1167                assert_eq!(n_bytes, 0);
1168                assert_eq!(read(&out_path).unwrap(), &[] as &[u8]);
1169            })
1170            .unwrap();
1171    }
1172
1173    #[test]
1174    fn test_download_to_writer_inverted_range_errors() {
1175        let runtime = get_runtime();
1176        runtime
1177            .clone()
1178            .external_run_async_task(async {
1179                let temp = tempdir().unwrap();
1180                let cas_path = temp.path().join("cas");
1181                let original_data = b"0123456789abcdef";
1182
1183                let xfi = upload_data(&cas_path, original_data).await;
1184
1185                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1186                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1187
1188                let out_path = temp.path().join("inverted_range.bin");
1189                let file = std::fs::File::create(&out_path).unwrap();
1190                let result = session.download_to_writer(&xfi, 10..5, file).await;
1191
1192                assert!(result.is_err());
1193            })
1194            .unwrap();
1195    }
1196
1197    #[cfg(not(debug_assertions))]
1198    #[test]
1199    fn test_download_to_writer_range_start_beyond_file_size_errors() {
1200        let runtime = get_runtime();
1201        runtime
1202            .clone()
1203            .external_run_async_task(async {
1204                let temp = tempdir().unwrap();
1205                let cas_path = temp.path().join("cas");
1206                let original_data = b"0123456789abcdef";
1207
1208                let xfi = upload_data(&cas_path, original_data).await;
1209
1210                let config = TranslatorConfig::local_config(&XetContext::default().unwrap(), &cas_path).unwrap();
1211                let session = FileDownloadSession::new(config.into(), None).await.unwrap();
1212
1213                let out_path = temp.path().join("beyond_size.bin");
1214                let file = std::fs::File::create(&out_path).unwrap();
1215                let result = session.download_to_writer(&xfi, 100000.., file).await;
1216
1217                assert!(result.is_err());
1218            })
1219            .unwrap();
1220    }
1221}