Skip to main content

xet_data/file_reconstruction/
file_reconstructor.rs

1#[cfg(not(target_family = "wasm"))]
2use std::fs::OpenOptions;
3use std::io::Write;
4#[cfg(not(target_family = "wasm"))]
5use std::io::{Seek, SeekFrom};
6#[cfg(not(target_family = "wasm"))]
7use std::path::Path;
8use std::sync::Arc;
9use std::sync::atomic::Ordering;
10
11use tokio_util::sync::CancellationToken;
12use tracing::{debug, info};
13use xet_client::cas_client::Client;
14use xet_client::cas_types::FileRange;
15use xet_client::chunk_cache::ChunkCache;
16use xet_core_structures::merklehash::MerkleHash;
17use xet_runtime::config::ReconstructionConfig;
18use xet_runtime::core::XetContext;
19use xet_runtime::utils::ClosureGuard;
20use xet_runtime::utils::adjustable_semaphore::AdjustableSemaphore;
21
22use super::data_writer::{DataWriter, DownloadStream, SequentialWriter, UnorderedDownloadStream};
23use super::error::{FileReconstructionError, Result};
24use super::reconstruction_terms::ReconstructionTermManager;
25use super::run_state::{RunError, RunState};
26use crate::progress_tracking::ItemProgressUpdater;
27
28/// Reconstructs a file from its content-addressed chunks by downloading xorb blocks
29/// and writing the reassembled data to an output. Supports byte range requests and
30/// uses memory-limited buffering with adaptive prefetching.
31pub struct FileReconstructor {
32    ctx: XetContext,
33    client: Arc<dyn Client>,
34    file_hash: MerkleHash,
35    byte_range: Option<FileRange>,
36    progress_updater: Option<Arc<ItemProgressUpdater>>,
37    config: Arc<ReconstructionConfig>,
38
39    /// Optional on-disk chunk cache for cross-file deduplication.
40    chunk_cache: Option<Arc<dyn ChunkCache>>,
41
42    /// Custom buffer semaphore for testing or specialized use cases.
43    custom_buffer_semaphore: Option<Arc<AdjustableSemaphore>>,
44
45    /// Cancellation token checked at each major step of the reconstruction loop.
46    /// When cancelled, reconstruction stops at its next check point. Long waits
47    /// (such as semaphore acquisition) use `tokio::select!` so they abort promptly.
48    cancellation_token: CancellationToken,
49}
50
51impl FileReconstructor {
52    pub fn new(ctx: &XetContext, client: &Arc<dyn Client>, file_hash: MerkleHash) -> Self {
53        Self {
54            ctx: ctx.clone(),
55            client: client.clone(),
56            file_hash,
57            byte_range: None,
58            progress_updater: default_progress_updater(),
59            config: Arc::new(ctx.config.reconstruction.clone()),
60            chunk_cache: None,
61            custom_buffer_semaphore: None,
62            cancellation_token: CancellationToken::new(),
63        }
64    }
65
66    pub fn with_byte_range(self, byte_range: FileRange) -> Self {
67        Self {
68            byte_range: Some(byte_range),
69            ..self
70        }
71    }
72
73    pub fn with_progress_updater(self, progress_updater: Arc<ItemProgressUpdater>) -> Self {
74        Self {
75            progress_updater: Some(progress_updater),
76            ..self
77        }
78    }
79
80    pub fn with_chunk_cache(self, cache: Arc<dyn ChunkCache>) -> Self {
81        Self {
82            chunk_cache: Some(cache),
83            ..self
84        }
85    }
86
87    pub fn with_config(self, config: impl AsRef<ReconstructionConfig>) -> Self {
88        Self {
89            config: Arc::new(config.as_ref().clone()),
90            ..self
91        }
92    }
93
94    /// Sets a custom buffer semaphore for controlling download buffer memory usage.
95    /// This is primarily useful for testing scenarios where you want to control
96    /// the timing of term fetches by limiting buffer capacity.
97    pub fn with_buffer_semaphore(self, semaphore: Arc<AdjustableSemaphore>) -> Self {
98        Self {
99            custom_buffer_semaphore: Some(semaphore),
100            ..self
101        }
102    }
103
104    /// Replaces the default cancellation token with the given one. This is used
105    /// when external code needs to share the same token for coordinated
106    /// cancellation.
107    pub fn with_cancellation_token(self, token: CancellationToken) -> Self {
108        Self {
109            cancellation_token: token,
110            ..self
111        }
112    }
113
114    /// Reconstructs the file and writes it to the given path.
115    ///
116    /// The file is opened with read/write access. When `truncate_file` is `true`
117    /// the file is truncated to the reconstructed length; when `false` the file
118    /// is left at its existing size, allowing multiple concurrent reconstructions
119    /// to write to different regions of the same file.
120    ///
121    /// When `write_offset` is `Some(offset)`, writing begins at that byte
122    /// position regardless of the byte range. When `None`, writing begins at
123    /// the byte range start (or 0 for a full-file reconstruction).
124    #[cfg(not(target_family = "wasm"))]
125    pub async fn reconstruct_to_file(self, path: &Path, write_offset: Option<u64>, truncate_file: bool) -> Result<u64> {
126        info!(
127            file_hash = %self.file_hash,
128            byte_range = ?self.byte_range,
129            path = %path.display(),
130            write_offset = ?write_offset,
131            "Reconstructing file to disk"
132        );
133
134        if let Some(parent) = path.parent() {
135            std::fs::create_dir_all(parent)?;
136        }
137
138        let mut file = OpenOptions::new().write(true).create(true).truncate(truncate_file).open(path)?;
139
140        let default_write_position = self.byte_range.map_or(0, |r| r.start);
141        let seek_position = write_offset.unwrap_or(default_write_position);
142        if seek_position > 0 {
143            file.seek(SeekFrom::Start(seek_position))?;
144        }
145
146        let run_state = RunState::new(self.cancellation_token.clone(), self.file_hash, self.progress_updater.clone());
147
148        let data_writer = SequentialWriter::new(&self.ctx, file, self.config.use_vectored_write, run_state.clone());
149
150        self.run(data_writer, run_state, false).await
151    }
152
153    /// Reconstructs the file and writes it to the given writer.
154    ///
155    /// The writer receives data starting from its current position (position 0
156    /// for a fresh writer), regardless of the byte range being reconstructed.
157    #[cfg(not(target_family = "wasm"))]
158    pub async fn reconstruct_to_writer<W: Write + Send + 'static>(self, writer: W) -> Result<u64> {
159        info!(
160            file_hash = %self.file_hash,
161            byte_range = ?self.byte_range,
162            "Reconstructing file to writer"
163        );
164
165        let run_state = RunState::new(self.cancellation_token.clone(), self.file_hash, self.progress_updater.clone());
166        let data_writer = SequentialWriter::new(&self.ctx, writer, self.config.use_vectored_write, run_state.clone());
167        self.run(data_writer, run_state, false).await
168    }
169
170    /// Reconstructs the file and writes it to the given writer.
171    ///
172    /// The writer receives data starting from its current position (position 0
173    /// for a fresh writer), regardless of the byte range being reconstructed.
174    ///
175    /// On wasm there is no background writer thread (wasm cannot block the host
176    /// thread), so this drives the streaming reconstruction path and copies each
177    /// chunk into the writer inline as it arrives.
178    #[cfg(target_family = "wasm")]
179    pub async fn reconstruct_to_writer<W: Write + Send + 'static>(self, mut writer: W) -> Result<u64> {
180        info!(
181            file_hash = %self.file_hash,
182            byte_range = ?self.byte_range,
183            "Reconstructing file to writer"
184        );
185
186        let mut stream = self.reconstruct_to_stream();
187        let mut total: u64 = 0;
188        while let Some(chunk) = stream.next().await? {
189            writer
190                .write_all(&chunk)
191                .map_err(|e| FileReconstructionError::IoError(Arc::new(e)))?;
192            total += chunk.len() as u64;
193        }
194        writer.flush().map_err(|e| FileReconstructionError::IoError(Arc::new(e)))?;
195        Ok(total)
196    }
197
198    /// Reconstructs the file as a stream, returning a [`DownloadStream`] that
199    /// yields data chunks as they become available.
200    ///
201    /// The reconstruction task is spawned immediately but pauses on an
202    /// internal [`tokio::sync::Notify`] until [`DownloadStream::start`] is
203    /// called (or the first [`DownloadStream::next`] /
204    /// [`DownloadStream::blocking_next`]).
205    ///
206    /// # Panics
207    ///
208    /// Panics if called outside a tokio runtime context (the constructor
209    /// uses [`tokio::spawn`]).
210    pub fn reconstruct_to_stream(self) -> DownloadStream {
211        let run_state = RunState::new(self.cancellation_token.clone(), self.file_hash, self.progress_updater.clone());
212
213        DownloadStream::new(self, run_state)
214    }
215
216    /// Reconstructs the file as an unordered stream, returning an
217    /// [`UnorderedDownloadStream`] that yields `(offset, Bytes)` chunks
218    /// in whatever order they complete.
219    ///
220    /// The reconstruction task is spawned immediately but pauses on an
221    /// internal [`tokio::sync::Notify`] until
222    /// [`UnorderedDownloadStream::start`] is called (or the first
223    /// [`UnorderedDownloadStream::next`] /
224    /// [`UnorderedDownloadStream::blocking_next`]).
225    ///
226    /// # Panics
227    ///
228    /// Panics if called outside a tokio runtime context (the constructor
229    /// uses [`tokio::spawn`]).
230    pub fn reconstruct_to_unordered_stream(self) -> UnorderedDownloadStream {
231        let run_state = RunState::new(self.cancellation_token.clone(), self.file_hash, self.progress_updater.clone());
232
233        UnorderedDownloadStream::new(self, run_state)
234    }
235
236    /// Runs the file reconstruction with error handling and cancellation support.
237    /// Returns the number of bytes written.
238    ///
239    /// When `is_streaming` is true, the progress completion assertions at the end
240    /// of reconstruction are skipped because the stream consumer reports bytes
241    /// asynchronously after this method returns.
242    pub(crate) async fn run(
243        self,
244        data_writer: Box<dyn DataWriter>,
245        run_state: Arc<RunState>,
246        is_streaming: bool,
247    ) -> Result<u64> {
248        match self.run_impl(data_writer, &run_state, is_streaming).await {
249            Ok(v) => Ok(v),
250            Err(RunError::Cancelled) => {
251                run_state.check_error()?;
252                Ok(0)
253            },
254            Err(RunError::Error(e)) => {
255                run_state.set_error(e.clone());
256                Err(e)
257            },
258        }
259    }
260
261    async fn run_impl(
262        self,
263        mut data_writer: Box<dyn DataWriter>,
264        run_state: &RunState,
265        _is_streaming: bool,
266    ) -> std::result::Result<u64, RunError> {
267        let Self {
268            ctx,
269            client,
270            byte_range,
271            config,
272            chunk_cache,
273            custom_buffer_semaphore,
274            ..
275        } = self;
276
277        run_state.check_run_state()?;
278
279        let file_hash = *run_state.file_hash();
280        let requested_range = byte_range.unwrap_or_else(FileRange::full);
281
282        let mut term_manager = ReconstructionTermManager::new(
283            ctx.clone(),
284            config.clone(),
285            client.clone(),
286            file_hash,
287            requested_range,
288            run_state.progress_updater().cloned(),
289        )
290        .await?;
291
292        let using_global_memory_limit = custom_buffer_semaphore.is_none();
293        let download_buffer_semaphore =
294            custom_buffer_semaphore.unwrap_or_else(|| ctx.common.reconstruction_download_buffer.clone());
295
296        // Dynamic buffer scaling: the target buffer size grows with the number of active
297        // downloads: target = (base + n * perfile).min(limit). On start we increment to
298        // the new target, possibly getting back a virtual permit that lets this download begin
299        // immediately without queuing behind existing acquires. On exit, the ClosureGuard
300        // recomputes the target for the reduced download count and shrinks back if needed.
301        let mut seed_buffer_permit;
302        let _download_count_decrement_guard;
303
304        if using_global_memory_limit {
305            let active_downloads = ctx.common.active_downloads.clone();
306            let n = active_downloads.fetch_add(1, Ordering::Relaxed) + 1;
307
308            let base = config.download_buffer_size.as_u64();
309            let perfile = config.download_buffer_perfile_size.as_u64();
310            let limit = config.download_buffer_limit.as_u64();
311
312            let target = base.saturating_add(n.saturating_mul(perfile)).min(limit);
313            seed_buffer_permit = download_buffer_semaphore.increment_permits_to_target(target);
314
315            let buffer_sem = download_buffer_semaphore.clone();
316            _download_count_decrement_guard = Some(ClosureGuard::new(move || {
317                let n = active_downloads.fetch_sub(1, Ordering::Relaxed).saturating_sub(1);
318                let target = base.saturating_add(n.saturating_mul(perfile)).min(limit);
319                buffer_sem.decrement_permits_to_target(target);
320            }));
321        } else {
322            seed_buffer_permit = None;
323            _download_count_decrement_guard = None;
324        }
325
326        // The range start offset - we need to adjust byte ranges to be relative to this.
327        let range_start_offset = requested_range.start;
328
329        // Outer loop: retrieve blocks of file terms.
330        // Use select! so a background error (which cancels the token) wakes this
331        // up immediately rather than waiting for the network round-trip to finish.
332        loop {
333            let maybe_file_terms = tokio::select! {
334                biased;
335                _ = run_state.cancelled() => {
336                    return run_state.check_run_state().map(|_| 0);
337                }
338                result = term_manager.next_file_terms() => result?
339            };
340
341            let Some(file_terms) = maybe_file_terms else {
342                break;
343            };
344
345            run_state.check_run_state()?;
346
347            run_state.record_new_block();
348
349            // Inner loop: process each file term in the block.
350            for file_term in file_terms {
351                run_state.check_run_state()?;
352
353                let term_size = file_term.byte_range.end - file_term.byte_range.start;
354
355                debug!(
356                    file_hash = %file_hash,
357                    xorb_hash = %file_term.xorb_block.xorb_hash,
358                    term_byte_range = ?(file_term.byte_range.start, file_term.byte_range.end),
359                    term_size,
360                    "Processing file term"
361                );
362
363                // Try to split from the reserved (virtual) permit first, giving this
364                // download immediate access without waiting in the FIFO queue.
365                // Fall back to the shared semaphore if the seed permit has been exhausted.
366                let buffer_permit = match seed_buffer_permit.as_mut().and_then(|rp| rp.split(term_size)) {
367                    Some(split) => split,
368                    None => {
369                        seed_buffer_permit = None;
370
371                        // Use tokio::select! to abort promptly if the run state fires.
372                        tokio::select! {
373                            biased;
374                            _ = run_state.cancelled() => {
375                                return run_state.check_run_state().map(|_| 0);
376                            }
377                            result = download_buffer_semaphore.acquire_many(term_size) => {
378                                result.map_err(|e| {
379                                    FileReconstructionError::InternalError(format!(
380                                        "Error acquiring download buffer permit: {e}"
381                                    ))
382                                })?
383                            }
384                        }
385                    },
386                };
387
388                let data_future = file_term
389                    .get_data_task(
390                        ctx.clone(),
391                        client.clone(),
392                        run_state.progress_updater().cloned(),
393                        chunk_cache.clone(),
394                    )
395                    .await?;
396
397                #[cfg(debug_assertions)]
398                {
399                    let refs = &file_term.xorb_block.references;
400                    assert!(refs.iter().any(|r| r.term_chunks == file_term.xorb_chunk_range));
401                }
402
403                // Adjust byte range to be relative to the requested range start (writer expects 0-based ranges).
404                let relative_byte_range = FileRange::new(
405                    file_term.byte_range.start - range_start_offset,
406                    file_term.byte_range.end - range_start_offset,
407                );
408
409                data_writer
410                    .set_next_term_data_source(relative_byte_range, Some(buffer_permit), data_future)
411                    .await?;
412
413                run_state.record_new_term(term_size);
414            }
415        }
416
417        run_state.log_progress("All term blocks received and scheduled for writing");
418
419        // Finish the data writer and wait for all data to be written.
420        let bytes_written = data_writer.finish().await?;
421        let total_bytes_scheduled = run_state.total_bytes_scheduled();
422
423        debug_assert_eq!(
424            bytes_written, total_bytes_scheduled,
425            "Bytes written ({bytes_written}) should match total bytes scheduled ({total_bytes_scheduled})"
426        );
427
428        run_state.log_progress("File reconstruction completed successfully");
429
430        #[cfg(debug_assertions)]
431        if !_is_streaming && let Some(updater) = run_state.progress_updater() {
432            updater.assert_complete();
433            if let Some(byte_range) = byte_range
434                && byte_range.end < u64::MAX
435            {
436                assert_eq!(updater.total_bytes_completed(), byte_range.end - byte_range.start);
437            }
438        }
439
440        Ok(total_bytes_scheduled)
441    }
442}
443
444#[cfg(test)]
445fn default_progress_updater() -> Option<Arc<ItemProgressUpdater>> {
446    Some(ItemProgressUpdater::new_standalone("test"))
447}
448
449#[cfg(not(test))]
450fn default_progress_updater() -> Option<Arc<ItemProgressUpdater>> {
451    None
452}
453
454#[cfg(test)]
455mod tests {
456    use std::io::{Cursor, Write};
457    use std::sync::Arc;
458    use std::sync::atomic::{AtomicUsize, Ordering};
459    use std::time::Duration;
460
461    use tokio::runtime::Handle;
462    use xet_client::cas_client::{ClientTestingUtils, DirectAccessClient, LocalClient, RandomFileContents};
463    use xet_client::cas_types::FileRange;
464    use xet_runtime::config::XetConfig;
465    use xet_runtime::core::XetContext;
466
467    use super::*;
468    use crate::progress_tracking::ItemProgressUpdater;
469
470    const TEST_CHUNK_SIZE: usize = 101;
471
472    /// Creates a test config with small fetch sizes to force multiple iterations.
473    fn test_config() -> ReconstructionConfig {
474        let mut config = ReconstructionConfig::default();
475        // Use small fetch sizes to force multiple prefetch iterations
476        config.min_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("100");
477        config.max_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("400");
478        config.min_prefetch_buffer = xet_runtime::utils::ByteSize::from("800");
479        config
480    }
481
482    /// Creates a test client and uploads a random file with the given term specification.
483    async fn setup_test_file(term_spec: &[(u64, (u64, u64))]) -> (Arc<LocalClient>, RandomFileContents) {
484        let client = LocalClient::temporary(XetContext::default().unwrap()).await.unwrap();
485        let file_contents = client.upload_random_file(term_spec, TEST_CHUNK_SIZE).await.unwrap();
486        (client, file_contents)
487    }
488
489    /// Reconstructs a file (or byte range) using a writer and returns the reconstructed data.
490    async fn reconstruct_to_vec(
491        client: &Arc<LocalClient>,
492        file_hash: MerkleHash,
493        byte_range: Option<FileRange>,
494        config: &ReconstructionConfig,
495        semaphore: Option<Arc<AdjustableSemaphore>>,
496    ) -> Result<Vec<u8>> {
497        let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
498        let writer = StaticCursorWriter(buffer.clone());
499
500        let mut reconstructor =
501            FileReconstructor::new(&XetContext::default().unwrap(), &(client.clone() as Arc<dyn Client>), file_hash)
502                .with_config(config);
503
504        if let Some(range) = byte_range {
505            reconstructor = reconstructor.with_byte_range(range);
506        }
507        if let Some(sem) = semaphore {
508            reconstructor = reconstructor.with_buffer_semaphore(sem);
509        }
510
511        reconstructor.reconstruct_to_writer(writer).await?;
512
513        let data = buffer.lock().unwrap().get_ref().clone();
514        Ok(data)
515    }
516
517    /// Reconstructs to a file and returns the reconstructed data.
518    /// Creates a temp file, reconstructs to it, then reads the relevant portion back.
519    async fn reconstruct_to_file(
520        client: &Arc<LocalClient>,
521        file_hash: MerkleHash,
522        byte_range: Option<FileRange>,
523        config: &ReconstructionConfig,
524    ) -> Result<Vec<u8>> {
525        let temp_dir = tempfile::tempdir().unwrap();
526        let file_path = temp_dir.path().join("output.bin");
527
528        let mut reconstructor =
529            FileReconstructor::new(&XetContext::default().unwrap(), &(client.clone() as Arc<dyn Client>), file_hash)
530                .with_config(config);
531
532        if let Some(range) = byte_range {
533            reconstructor = reconstructor.with_byte_range(range);
534        }
535
536        reconstructor.reconstruct_to_file(&file_path, None, false).await?;
537
538        // Read back the data from the file at the expected location.
539        let file_data = std::fs::read(&file_path)?;
540        let start = byte_range.map(|r| r.start as usize).unwrap_or(0);
541        Ok(file_data[start..].to_vec())
542    }
543
544    /// Reconstructs to a file at a specific offset and returns the data.
545    async fn reconstruct_to_file_at_specific_offset(
546        client: &Arc<LocalClient>,
547        file_hash: MerkleHash,
548        byte_range: Option<FileRange>,
549        config: &ReconstructionConfig,
550    ) -> Result<Vec<u8>> {
551        let offset = 9u64;
552
553        let temp_dir = tempfile::tempdir().unwrap();
554        let file_path = temp_dir.path().join("output.bin");
555
556        let mut reconstructor =
557            FileReconstructor::new(&XetContext::default().unwrap(), &(client.clone() as Arc<dyn Client>), file_hash)
558                .with_config(config);
559
560        if let Some(range) = byte_range {
561            reconstructor = reconstructor.with_byte_range(range);
562        }
563
564        reconstructor.reconstruct_to_file(&file_path, Some(offset), false).await?;
565
566        // Read back all file data.
567        let file_data = std::fs::read(&file_path)?;
568        Ok(file_data[offset as usize..].to_vec())
569    }
570
571    /// Reconstructs to a file at offset 0 and returns the data.
572    /// This tests writing to the beginning of a file regardless of the byte range.
573    async fn reconstruct_to_file_at_offset_zero(
574        client: &Arc<LocalClient>,
575        file_hash: MerkleHash,
576        byte_range: Option<FileRange>,
577        config: &ReconstructionConfig,
578    ) -> Result<Vec<u8>> {
579        let temp_dir = tempfile::tempdir().unwrap();
580        let file_path = temp_dir.path().join("output.bin");
581
582        let mut reconstructor =
583            FileReconstructor::new(&XetContext::default().unwrap(), &(client.clone() as Arc<dyn Client>), file_hash)
584                .with_config(config);
585
586        if let Some(range) = byte_range {
587            reconstructor = reconstructor.with_byte_range(range);
588        }
589
590        reconstructor.reconstruct_to_file(&file_path, Some(0), false).await?;
591
592        // Read back all file data (it starts at offset 0).
593        let file_data = std::fs::read(&file_path)?;
594        Ok(file_data)
595    }
596
597    /// A wrapper that allows writing to a shared Vec; needed for testing
598    /// with the 'static cursor writer present in the code.
599    struct StaticCursorWriter(Arc<std::sync::Mutex<Cursor<Vec<u8>>>>);
600
601    impl std::io::Write for StaticCursorWriter {
602        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
603            self.0.lock().unwrap().write(buf)
604        }
605
606        fn flush(&mut self) -> std::io::Result<()> {
607            self.0.lock().unwrap().flush()
608        }
609    }
610
611    /// Reconstructs and verifies the full file using all output methods and vectored/non-vectored writes.
612    async fn reconstruct_and_verify_full(
613        client: &Arc<LocalClient>,
614        file_contents: &RandomFileContents,
615        base_config: ReconstructionConfig,
616    ) {
617        let expected = &file_contents.data;
618        let h = file_contents.file_hash;
619
620        // Test both vectored and non-vectored write paths.
621        for use_vectored in [false, true] {
622            let mut config = base_config.clone();
623            config.use_vectored_write = use_vectored;
624
625            // Test 1: reconstruct_to_writer
626            let vec_result = reconstruct_to_vec(&client, h, None, &config, None).await.unwrap();
627            assert_eq!(vec_result, *expected, "vec failed (vectored={use_vectored})");
628
629            // Test 2: reconstruct_to_file
630            let file_result = reconstruct_to_file(client, h, None, &config).await.unwrap();
631            assert_eq!(file_result, *expected, "file failed (vectored={use_vectored})");
632
633            // Test 3: reconstruct_to_file with offset 0
634            let file_offset_result = reconstruct_to_file_at_offset_zero(client, h, None, &config).await.unwrap();
635            assert_eq!(file_offset_result, *expected, "file_at_offset_zero failed (vectored={use_vectored})");
636
637            // Test 4: reconstruct_to_file with specific offset
638            let file_specific_result = reconstruct_to_file_at_specific_offset(client, h, None, &config).await.unwrap();
639            assert_eq!(file_specific_result, *expected, "file_at_specific_offset failed (vectored={use_vectored})");
640        }
641    }
642
643    /// Reconstructs and verifies a byte range using all output methods and vectored/non-vectored writes.
644    async fn reconstruct_and_verify_range(
645        client: &Arc<LocalClient>,
646        file_contents: &RandomFileContents,
647        range: FileRange,
648        base_config: ReconstructionConfig,
649    ) {
650        let expected = &file_contents.data[range.start as usize..range.end as usize];
651
652        // Test both vectored and non-vectored write paths.
653        for use_vectored in [false, true] {
654            let mut config = base_config.clone();
655            config.use_vectored_write = use_vectored;
656
657            // Test 1: reconstruct_to_writer
658            let vec_result = reconstruct_to_vec(&client, file_contents.file_hash, Some(range), &config, None)
659                .await
660                .expect("reconstruct_to_vec should succeed");
661            assert_eq!(vec_result, expected, "vec failed (vectored={use_vectored})");
662
663            // Test 2: reconstruct_to_file
664            let file_result = reconstruct_to_file(client, file_contents.file_hash, Some(range), &config)
665                .await
666                .expect("reconstruct_to_file should succeed");
667            assert_eq!(file_result, expected, "file failed (vectored={use_vectored})");
668
669            // Test 3: reconstruct_to_file with offset 0
670            let file_offset_result =
671                reconstruct_to_file_at_offset_zero(client, file_contents.file_hash, Some(range), &config)
672                    .await
673                    .expect("reconstruct_to_file_at_offset_zero should succeed");
674            assert_eq!(file_offset_result, expected, "file_at_offset failed (vectored={use_vectored})");
675        }
676    }
677
678    // ==================== Full File Reconstruction Tests ====================
679
680    #[tokio::test]
681    async fn test_single_term_full_reconstruction() {
682        let (client, file_contents) = setup_test_file(&[(1, (0, 3))]).await;
683        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
684    }
685
686    #[tokio::test]
687    async fn test_multiple_terms_same_xorb_full_reconstruction() {
688        let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (1, (2, 4)), (1, (4, 6))]).await;
689        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
690    }
691
692    #[tokio::test]
693    async fn test_multiple_xorbs_full_reconstruction() {
694        let (client, file_contents) = setup_test_file(&[(1, (0, 3)), (2, (0, 2)), (3, (0, 4))]).await;
695        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
696    }
697
698    #[tokio::test]
699    async fn test_large_file_many_terms_full_reconstruction() {
700        // Create a file large enough to require multiple prefetch iterations
701        let term_spec: Vec<(u64, (u64, u64))> = (1..=10).map(|i| (i, (0, 5))).collect();
702        let (client, file_contents) = setup_test_file(&term_spec).await;
703        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
704    }
705
706    #[tokio::test]
707    async fn test_interleaved_xorbs_full_reconstruction() {
708        let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (2, (0, 2)), (1, (2, 4)), (2, (2, 4))]).await;
709        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
710    }
711
712    #[tokio::test]
713    async fn test_single_chunk_file() {
714        let (client, file_contents) = setup_test_file(&[(1, (0, 1))]).await;
715        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
716    }
717
718    #[tokio::test]
719    async fn test_many_small_terms_different_xorbs() {
720        let term_spec: Vec<(u64, (u64, u64))> = (1..=20).map(|i| (i, (0, 1))).collect();
721        let (client, file_contents) = setup_test_file(&term_spec).await;
722        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
723    }
724
725    // ==================== Progress tracker tests ====================
726
727    #[tokio::test]
728    async fn test_progress_tracker_records_full_reconstruction_bytes() {
729        let (client, file_contents) = setup_test_file(&[(1, (0, 3)), (2, (0, 2))]).await;
730        let config = test_config();
731        let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
732        let writer = StaticCursorWriter(buffer.clone());
733
734        let progress_updater = ItemProgressUpdater::new_standalone("file");
735        let bytes_written = FileReconstructor::new(
736            &XetContext::default().unwrap(),
737            &(client.clone() as Arc<dyn Client>),
738            file_contents.file_hash,
739        )
740        .with_config(&config)
741        .with_progress_updater(progress_updater.clone())
742        .reconstruct_to_writer(writer)
743        .await
744        .unwrap();
745
746        assert_eq!(bytes_written, file_contents.data.len() as u64);
747    }
748
749    #[tokio::test]
750    async fn test_progress_tracker_records_partial_range_bytes() {
751        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
752        let config = test_config();
753        let file_len = file_contents.data.len() as u64;
754        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
755        let expected_bytes = range.end - range.start;
756
757        let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
758        let writer = StaticCursorWriter(buffer.clone());
759
760        let progress_updater = ItemProgressUpdater::new_standalone("file");
761        let bytes_written = FileReconstructor::new(
762            &XetContext::default().unwrap(),
763            &(client.clone() as Arc<dyn Client>),
764            file_contents.file_hash,
765        )
766        .with_config(&config)
767        .with_byte_range(range)
768        .with_progress_updater(progress_updater.clone())
769        .reconstruct_to_writer(writer)
770        .await
771        .unwrap();
772
773        assert_eq!(bytes_written, expected_bytes);
774    }
775
776    /// Verifies the external progress tracker flow without a known file size:
777    /// totals are discovered incrementally by the ReconstructionTermManager.
778    #[tokio::test]
779    async fn test_external_progress_tracker_incremental_discovery() {
780        let term_spec: Vec<(u64, (u64, u64))> = (1..=5).map(|i| (i, (0, 3))).collect();
781        let (client, file_contents) = setup_test_file(&term_spec).await;
782        let config = test_config();
783
784        let task = ItemProgressUpdater::new_standalone("test_file.bin");
785
786        let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
787        let writer = StaticCursorWriter(buffer.clone());
788
789        let bytes_written = FileReconstructor::new(
790            &XetContext::default().unwrap(),
791            &(client.clone() as Arc<dyn Client>),
792            file_contents.file_hash,
793        )
794        .with_config(&config)
795        .with_progress_updater(task.clone())
796        .reconstruct_to_writer(writer)
797        .await
798        .unwrap();
799
800        assert_eq!(bytes_written, file_contents.data.len() as u64);
801
802        task.assert_complete();
803        assert_eq!(task.total_bytes_completed(), file_contents.data.len() as u64);
804    }
805
806    /// Verifies the data_client.rs flow: file size is known upfront (is_final=true),
807    /// then the manager discovers transfer sizes and also tries to update_item_size
808    /// (which is ignored since final was already set).
809    #[tokio::test]
810    async fn test_external_progress_tracker_final_size_upfront() {
811        let term_spec: Vec<(u64, (u64, u64))> = (1..=5).map(|i| (i, (0, 3))).collect();
812        let (client, file_contents) = setup_test_file(&term_spec).await;
813        let config = test_config();
814        let file_size = file_contents.data.len() as u64;
815
816        let task = ItemProgressUpdater::new_standalone("test_file.bin");
817
818        task.update_item_size(file_size, true);
819
820        let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
821        let writer = StaticCursorWriter(buffer.clone());
822
823        let bytes_written = FileReconstructor::new(
824            &XetContext::default().unwrap(),
825            &(client.clone() as Arc<dyn Client>),
826            file_contents.file_hash,
827        )
828        .with_config(&config)
829        .with_progress_updater(task.clone())
830        .reconstruct_to_writer(writer)
831        .await
832        .unwrap();
833
834        assert_eq!(bytes_written, file_size);
835
836        assert_eq!(task.total_bytes_completed(), file_size);
837
838        task.assert_complete();
839    }
840
841    // ==================== Byte Range Reconstruction Tests ====================
842
843    #[tokio::test]
844    async fn test_range_first_half() {
845        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
846        let file_len = file_contents.data.len() as u64;
847        let range = FileRange::new(0, file_len / 2);
848        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
849    }
850
851    #[tokio::test]
852    async fn test_range_second_half() {
853        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
854        let file_len = file_contents.data.len() as u64;
855        let range = FileRange::new(file_len / 2, file_len);
856        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
857    }
858
859    #[tokio::test]
860    async fn test_range_middle() {
861        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
862        let file_len = file_contents.data.len() as u64;
863        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
864        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
865    }
866
867    #[tokio::test]
868    async fn test_range_single_byte_start() {
869        let (client, file_contents) = setup_test_file(&[(1, (0, 5))]).await;
870        let range = FileRange::new(0, 1);
871        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
872    }
873
874    #[tokio::test]
875    async fn test_range_single_byte_end() {
876        let (client, file_contents) = setup_test_file(&[(1, (0, 5))]).await;
877        let file_len = file_contents.data.len() as u64;
878        let range = FileRange::new(file_len - 1, file_len);
879        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
880    }
881
882    #[tokio::test]
883    async fn test_range_single_byte_middle() {
884        let (client, file_contents) = setup_test_file(&[(1, (0, 5))]).await;
885        let file_len = file_contents.data.len() as u64;
886        let mid = file_len / 2;
887        let range = FileRange::new(mid, mid + 1);
888        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
889    }
890
891    #[tokio::test]
892    async fn test_range_few_bytes_from_start() {
893        let (client, file_contents) = setup_test_file(&[(1, (0, 5))]).await;
894        let file_len = file_contents.data.len() as u64;
895        let range = FileRange::new(3, file_len);
896        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
897    }
898
899    #[tokio::test]
900    async fn test_range_few_bytes_before_end() {
901        let (client, file_contents) = setup_test_file(&[(1, (0, 5))]).await;
902        let file_len = file_contents.data.len() as u64;
903        let range = FileRange::new(0, file_len - 3);
904        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
905    }
906
907    #[tokio::test]
908    async fn test_range_small_slice_in_middle() {
909        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
910        let file_len = file_contents.data.len() as u64;
911        let range = FileRange::new(file_len / 3, file_len / 3 + 10);
912        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
913    }
914
915    // ==================== Multi-term Range Tests ====================
916
917    #[tokio::test]
918    async fn test_range_spanning_multiple_terms() {
919        let (client, file_contents) = setup_test_file(&[(1, (0, 3)), (2, (0, 3)), (3, (0, 3))]).await;
920        let file_len = file_contents.data.len() as u64;
921        // Range that spans all three terms but not full file
922        let range = FileRange::new(10, file_len - 10);
923        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
924    }
925
926    #[tokio::test]
927    async fn test_range_within_single_term() {
928        let (client, file_contents) = setup_test_file(&[(1, (0, 10)), (2, (0, 10))]).await;
929        // First term size
930        let first_term_size = file_contents.terms[0].data.len() as u64;
931        // Range within the first term only
932        let range = FileRange::new(5, first_term_size - 5);
933        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
934    }
935
936    #[tokio::test]
937    async fn test_range_crossing_term_boundary() {
938        let (client, file_contents) = setup_test_file(&[(1, (0, 5)), (2, (0, 5))]).await;
939        let first_term_size = file_contents.terms[0].data.len() as u64;
940        // Range that straddles the boundary between terms
941        let range = FileRange::new(first_term_size - 10, first_term_size + 10);
942        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
943    }
944
945    // ==================== Edge Cases with Multiple Prefetch Iterations ====================
946
947    #[tokio::test]
948    async fn test_large_file_range_first_portion() {
949        // Large file to ensure multiple prefetch iterations
950        let term_spec: Vec<(u64, (u64, u64))> = (1..=15).map(|i| (i, (0, 4))).collect();
951        let (client, file_contents) = setup_test_file(&term_spec).await;
952        let file_len = file_contents.data.len() as u64;
953        let range = FileRange::new(0, file_len / 3);
954        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
955    }
956
957    #[tokio::test]
958    async fn test_large_file_range_last_portion() {
959        let term_spec: Vec<(u64, (u64, u64))> = (1..=15).map(|i| (i, (0, 4))).collect();
960        let (client, file_contents) = setup_test_file(&term_spec).await;
961        let file_len = file_contents.data.len() as u64;
962        let range = FileRange::new(file_len * 2 / 3, file_len);
963        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
964    }
965
966    #[tokio::test]
967    async fn test_large_file_range_middle_portion() {
968        let term_spec: Vec<(u64, (u64, u64))> = (1..=15).map(|i| (i, (0, 4))).collect();
969        let (client, file_contents) = setup_test_file(&term_spec).await;
970        let file_len = file_contents.data.len() as u64;
971        let range = FileRange::new(file_len / 3, file_len * 2 / 3);
972        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
973    }
974
975    // ==================== Complex File Structures ====================
976
977    #[tokio::test]
978    async fn test_complex_mixed_pattern_full() {
979        let term_spec = &[
980            (1, (0, 3)),
981            (2, (0, 2)),
982            (1, (3, 5)),
983            (3, (1, 4)),
984            (2, (4, 6)),
985            (1, (0, 2)),
986        ];
987        let (client, file_contents) = setup_test_file(term_spec).await;
988        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
989    }
990
991    #[tokio::test]
992    async fn test_complex_mixed_pattern_partial_range() {
993        let term_spec = &[
994            (1, (0, 3)),
995            (2, (0, 2)),
996            (1, (3, 5)),
997            (3, (1, 4)),
998            (2, (4, 6)),
999            (1, (0, 2)),
1000        ];
1001        let (client, file_contents) = setup_test_file(term_spec).await;
1002        let file_len = file_contents.data.len() as u64;
1003        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1004        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
1005    }
1006
1007    #[tokio::test]
1008    async fn test_overlapping_chunk_ranges() {
1009        let (client, file_contents) = setup_test_file(&[(1, (0, 5)), (1, (1, 3)), (1, (2, 4))]).await;
1010        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1011    }
1012
1013    #[tokio::test]
1014    async fn test_non_contiguous_chunks() {
1015        let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (1, (4, 6))]).await;
1016        let config = test_config();
1017        let result = reconstruct_to_vec(&client, file_contents.file_hash, None, &config, None)
1018            .await
1019            .unwrap();
1020        assert_eq!(result, file_contents.data);
1021    }
1022
1023    // ==================== Default Config Tests ====================
1024
1025    #[tokio::test]
1026    async fn test_default_config_full_reconstruction() {
1027        let (client, file_contents) = setup_test_file(&[(1, (0, 5)), (2, (0, 3))]).await;
1028        // Use default config (larger fetch sizes)
1029        reconstruct_and_verify_full(&client, &file_contents, ReconstructionConfig::default()).await;
1030    }
1031
1032    #[tokio::test]
1033    async fn test_default_config_partial_range() {
1034        let (client, file_contents) = setup_test_file(&[(1, (0, 5)), (2, (0, 3))]).await;
1035        let file_len = file_contents.data.len() as u64;
1036        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1037        reconstruct_and_verify_range(&client, &file_contents, range, ReconstructionConfig::default()).await;
1038    }
1039
1040    // ==================== URL Refresh Tests ====================
1041    //
1042    // These tests verify that URL refresh logic works correctly when URLs expire.
1043    // We use tokio's time advancement (start_paused = true) to control time precisely.
1044
1045    /// A writer that advances tokio time after each write, causing URL expiration.
1046    /// This forces the reconstruction logic to refresh URLs for subsequent fetches.
1047    struct TimeAdvancingWriter {
1048        buffer: Arc<std::sync::Mutex<Vec<u8>>>,
1049        advance_duration: Duration,
1050        write_count: Arc<AtomicUsize>,
1051    }
1052
1053    impl TimeAdvancingWriter {
1054        fn new(advance_duration: Duration) -> Self {
1055            Self {
1056                buffer: Arc::new(std::sync::Mutex::new(Vec::new())),
1057                advance_duration,
1058                write_count: Arc::new(AtomicUsize::new(0)),
1059            }
1060        }
1061    }
1062
1063    impl Write for TimeAdvancingWriter {
1064        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1065            let bytes_written = self.buffer.lock().unwrap().write(buf)?;
1066
1067            // Increment write count
1068            self.write_count.fetch_add(1, Ordering::Relaxed);
1069
1070            // Advance tokio time to cause URL expiration for next fetch.
1071            // Use Handle::block_on directly since we're in a spawn_blocking context
1072            // (block_in_place is not allowed from blocking threads).
1073            let advance_duration = self.advance_duration;
1074            tokio::runtime::Handle::current().block_on(async {
1075                tokio::time::advance(advance_duration).await;
1076            });
1077
1078            Ok(bytes_written)
1079        }
1080
1081        fn flush(&mut self) -> std::io::Result<()> {
1082            Ok(())
1083        }
1084    }
1085
1086    /// Creates a config with very small fetch sizes to ensure we get multiple terms.
1087    fn url_refresh_test_config() -> ReconstructionConfig {
1088        let mut config = ReconstructionConfig::default();
1089        // Very small fetch sizes to force multiple term blocks
1090        config.min_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("50");
1091        config.max_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("100");
1092        config.min_prefetch_buffer = xet_runtime::utils::ByteSize::from("50");
1093        config
1094    }
1095
1096    /// Test that URL refresh works correctly when URLs expire between term fetches.
1097    /// Uses a tiny buffer semaphore (1 byte) to force sequential term processing,
1098    /// and advances time after each write to cause URL expiration.
1099    #[tokio::test(start_paused = true)]
1100    async fn test_url_refresh_on_expiration() {
1101        // Create a file with multiple terms from multiple xorbs
1102        let term_spec = &[(1, (0, 2)), (2, (0, 2)), (3, (0, 2))];
1103        let (client, file_contents) = setup_test_file(term_spec).await;
1104
1105        // Set a short URL expiration (1 second)
1106        let url_expiration = Duration::from_secs(1);
1107        client.set_fetch_term_url_expiration(url_expiration);
1108
1109        // Create a writer that advances time by more than the expiration after each write
1110        let time_advance = Duration::from_secs(2);
1111        let writer = TimeAdvancingWriter::new(time_advance);
1112        let writer_buffer = writer.buffer.clone();
1113        let write_count = writer.write_count.clone();
1114
1115        // Create a tiny semaphore (1 permit) to force sequential processing
1116        // This ensures each term is fully written before the next is fetched
1117        let tiny_semaphore = AdjustableSemaphore::new(1, (1, 1));
1118        let ctx = XetContext::from_external(Handle::current(), XetConfig::new());
1119
1120        FileReconstructor::new(&ctx, &(client.clone() as Arc<dyn Client>), file_contents.file_hash)
1121            .with_config(url_refresh_test_config())
1122            .with_buffer_semaphore(tiny_semaphore)
1123            .reconstruct_to_writer(writer)
1124            .await
1125            .expect("Reconstruction should succeed with URL refresh");
1126
1127        // Verify the reconstructed data is correct
1128        let reconstructed = writer_buffer.lock().unwrap().clone();
1129        assert_eq!(reconstructed.len(), file_contents.data.len());
1130        assert_eq!(reconstructed, file_contents.data);
1131
1132        // Verify we had multiple writes (one per term at minimum)
1133        assert!(write_count.load(Ordering::Relaxed) >= term_spec.len());
1134    }
1135
1136    /// Test URL refresh with a single xorb but multiple terms.
1137    /// This tests the case where the cached xorb data should still be valid
1138    /// but the URL needs refreshing.
1139    #[tokio::test(start_paused = true)]
1140    async fn test_url_refresh_same_xorb_multiple_terms() {
1141        // Create multiple terms from the same xorb
1142        let term_spec = &[(1, (0, 2)), (1, (2, 4)), (1, (4, 6))];
1143        let (client, file_contents) = setup_test_file(term_spec).await;
1144
1145        // Set a short URL expiration
1146        client.set_fetch_term_url_expiration(Duration::from_secs(1));
1147
1148        // Create a writer that advances time
1149        let writer = TimeAdvancingWriter::new(Duration::from_secs(2));
1150        let writer_buffer = writer.buffer.clone();
1151
1152        let tiny_semaphore = AdjustableSemaphore::new(1, (1, 1));
1153        let ctx = XetContext::from_external(Handle::current(), XetConfig::new());
1154
1155        FileReconstructor::new(&ctx, &(client.clone() as Arc<dyn Client>), file_contents.file_hash)
1156            .with_config(url_refresh_test_config())
1157            .with_buffer_semaphore(tiny_semaphore)
1158            .reconstruct_to_writer(writer)
1159            .await
1160            .expect("Reconstruction should succeed");
1161
1162        let reconstructed = writer_buffer.lock().unwrap().clone();
1163        assert_eq!(reconstructed, file_contents.data);
1164    }
1165
1166    /// Test URL refresh with a larger file that requires multiple prefetch blocks.
1167    #[tokio::test(start_paused = true)]
1168    async fn test_url_refresh_large_file_multiple_blocks() {
1169        // Create a larger file with many terms
1170        let term_spec: Vec<(u64, (u64, u64))> = (1..=5).map(|i| (i, (0, 3))).collect();
1171        let (client, file_contents) = setup_test_file(&term_spec).await;
1172
1173        // Set a short URL expiration
1174        client.set_fetch_term_url_expiration(Duration::from_secs(1));
1175
1176        let writer = TimeAdvancingWriter::new(Duration::from_secs(2));
1177        let writer_buffer = writer.buffer.clone();
1178
1179        let tiny_semaphore = AdjustableSemaphore::new(1, (1, 1));
1180        let ctx = XetContext::from_external(Handle::current(), XetConfig::new());
1181
1182        FileReconstructor::new(&ctx, &(client.clone() as Arc<dyn Client>), file_contents.file_hash)
1183            .with_config(url_refresh_test_config())
1184            .with_buffer_semaphore(tiny_semaphore)
1185            .reconstruct_to_writer(writer)
1186            .await
1187            .expect("Reconstruction should succeed");
1188
1189        let reconstructed = writer_buffer.lock().unwrap().clone();
1190        assert_eq!(reconstructed, file_contents.data);
1191    }
1192
1193    /// Test that reconstruction works when URLs don't expire (control test).
1194    #[tokio::test(start_paused = true)]
1195    async fn test_no_url_expiration_control() {
1196        let term_spec = &[(1, (0, 2)), (2, (0, 2)), (3, (0, 2))];
1197        let (client, file_contents) = setup_test_file(term_spec).await;
1198
1199        // Set a long URL expiration that won't trigger
1200        client.set_fetch_term_url_expiration(Duration::from_secs(3600));
1201
1202        // Advance time only slightly (less than expiration)
1203        let writer = TimeAdvancingWriter::new(Duration::from_millis(100));
1204        let writer_buffer = writer.buffer.clone();
1205
1206        let tiny_semaphore = AdjustableSemaphore::new(1, (1, 1));
1207        let ctx = XetContext::from_external(Handle::current(), XetConfig::new());
1208
1209        FileReconstructor::new(&ctx, &(client.clone() as Arc<dyn Client>), file_contents.file_hash)
1210            .with_config(url_refresh_test_config())
1211            .with_buffer_semaphore(tiny_semaphore)
1212            .reconstruct_to_writer(writer)
1213            .await
1214            .expect("Reconstruction should succeed");
1215
1216        let reconstructed = writer_buffer.lock().unwrap().clone();
1217        assert_eq!(reconstructed, file_contents.data);
1218    }
1219
1220    /// Test partial range reconstruction with URL refresh.
1221    #[tokio::test(start_paused = true)]
1222    async fn test_url_refresh_partial_range() {
1223        let term_spec = &[(1, (0, 5)), (2, (0, 5))];
1224        let (client, file_contents) = setup_test_file(term_spec).await;
1225        let file_len = file_contents.data.len() as u64;
1226
1227        client.set_fetch_term_url_expiration(Duration::from_secs(1));
1228
1229        let writer = TimeAdvancingWriter::new(Duration::from_secs(2));
1230        let writer_buffer = writer.buffer.clone();
1231
1232        let tiny_semaphore = AdjustableSemaphore::new(1, (0, 1));
1233        let ctx = XetContext::from_external(Handle::current(), XetConfig::new());
1234
1235        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1236
1237        FileReconstructor::new(&ctx, &(client.clone() as Arc<dyn Client>), file_contents.file_hash)
1238            .with_byte_range(range)
1239            .with_config(url_refresh_test_config())
1240            .with_buffer_semaphore(tiny_semaphore)
1241            .reconstruct_to_writer(writer)
1242            .await
1243            .expect("Reconstruction should succeed");
1244
1245        let reconstructed = writer_buffer.lock().unwrap().clone();
1246        let expected = &file_contents.data[range.start as usize..range.end as usize];
1247        assert_eq!(reconstructed, expected);
1248    }
1249
1250    #[test]
1251    fn test_dynamic_buffer_scaling_noop_increment_preserves_total_permits() {
1252        let mut runtime_config = xet_runtime::config::XetConfig::new();
1253        runtime_config.reconstruction.download_buffer_size = xet_runtime::utils::ByteSize::from("1kb");
1254        runtime_config.reconstruction.download_buffer_limit = xet_runtime::utils::ByteSize::from("4kb");
1255        let expected_total = runtime_config.reconstruction.download_buffer_limit.as_u64();
1256
1257        let ctx = XetContext::with_config(runtime_config).unwrap();
1258        let runtime = ctx.runtime.clone();
1259
1260        runtime
1261            .bridge_sync(async move {
1262                let ctx = ctx.clone();
1263                let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (2, (0, 2)), (3, (0, 2))]).await;
1264                let sem = ctx.common.reconstruction_download_buffer.clone();
1265
1266                // Pre-grow to max so the run's increment request is a no-op.
1267                let p = sem.increment_total_permits(u64::MAX).unwrap();
1268                drop(p);
1269                assert_eq!(sem.total_permits(), expected_total);
1270
1271                let mut config = test_config();
1272                config.download_buffer_perfile_size = xet_runtime::utils::ByteSize::from("8kb");
1273
1274                let reconstructed = reconstruct_to_vec(&client, file_contents.file_hash, None, &config, None)
1275                    .await
1276                    .unwrap();
1277                assert_eq!(reconstructed, file_contents.data);
1278
1279                assert_eq!(sem.total_permits(), expected_total);
1280                assert_eq!(ctx.common.active_downloads.load(Ordering::Relaxed), 0);
1281            })
1282            .unwrap();
1283    }
1284
1285    // ==================== File Output Specific Tests ====================
1286    // Note: Basic file output is tested via reconstruct_and_verify_full/range.
1287    // These tests cover file-specific scenarios like multiple writes to the same file.
1288
1289    /// Helper to reconstruct to a specific file path (for multi-write tests).
1290    async fn reconstruct_range_to_file_path(
1291        client: &Arc<LocalClient>,
1292        file_hash: MerkleHash,
1293        file_path: &std::path::Path,
1294        range: FileRange,
1295        config: ReconstructionConfig,
1296    ) -> Result<u64> {
1297        FileReconstructor::new(&XetContext::default().unwrap(), &(client.clone() as Arc<dyn Client>), file_hash)
1298            .with_byte_range(range)
1299            .with_config(config)
1300            .reconstruct_to_file(file_path, None, false)
1301            .await
1302    }
1303
1304    #[tokio::test]
1305    async fn test_file_concurrent_non_overlapping_range_writes() {
1306        // Test 16 concurrent writers writing non-overlapping ranges to a ~1MB file.
1307        const NUM_WRITERS: usize = 16;
1308        const LARGE_CHUNK_SIZE: usize = 4096;
1309
1310        // Create a large file (~1MB) with many xorbs.
1311        // Each xorb has ~64KB of data (16 chunks * 4KB), giving us ~1MB total with 16 xorbs.
1312        let term_spec: Vec<(u64, (u64, u64))> = (1..=16).map(|i| (i, (0, 16))).collect();
1313
1314        let client = LocalClient::temporary(XetContext::default().unwrap()).await.unwrap();
1315        let file_contents = client.upload_random_file(&term_spec, LARGE_CHUNK_SIZE).await.unwrap();
1316        let file_len = file_contents.data.len() as u64;
1317
1318        let temp_dir = tempfile::tempdir().unwrap();
1319        let file_path = temp_dir.path().join("output.bin");
1320
1321        // Pre-create the file with zeros.
1322        std::fs::write(&file_path, vec![0u8; file_len as usize]).unwrap();
1323
1324        // Use a config with larger fetch sizes for the concurrent test.
1325        let mut config = ReconstructionConfig::default();
1326        config.min_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("32kb");
1327        config.max_reconstruction_fetch_size = xet_runtime::utils::ByteSize::from("128kb");
1328
1329        // Create 16 non-overlapping ranges.
1330        let chunk_size = file_len / NUM_WRITERS as u64;
1331        let ranges: Vec<FileRange> = (0..NUM_WRITERS)
1332            .map(|i| {
1333                let start = i as u64 * chunk_size;
1334                let end = if i == NUM_WRITERS - 1 {
1335                    file_len
1336                } else {
1337                    (i as u64 + 1) * chunk_size
1338                };
1339                FileRange::new(start, end)
1340            })
1341            .collect();
1342
1343        // Spawn all writers concurrently using a JoinSet.
1344        let mut join_set = tokio::task::JoinSet::new();
1345
1346        for range in ranges {
1347            let client = client.clone();
1348            let file_hash = file_contents.file_hash;
1349            let file_path = file_path.clone();
1350            let config = config.clone();
1351
1352            join_set.spawn(async move {
1353                FileReconstructor::new(&XetContext::default().unwrap(), &(client as Arc<dyn Client>), file_hash)
1354                    .with_byte_range(range)
1355                    .with_config(config)
1356                    .reconstruct_to_file(&file_path, None, false)
1357                    .await
1358            });
1359        }
1360
1361        // Wait for all writers to complete.
1362        while let Some(result) = join_set.join_next().await {
1363            result.unwrap().unwrap();
1364        }
1365
1366        // Verify the complete file.
1367        let reconstructed = std::fs::read(&file_path).unwrap();
1368        assert_eq!(reconstructed.len(), file_contents.data.len());
1369        assert_eq!(reconstructed, file_contents.data);
1370    }
1371
1372    #[tokio::test]
1373    async fn test_file_writes_preserve_existing_content() {
1374        // Test that writing a range doesn't affect content outside that range.
1375        let (client, file_contents) = setup_test_file(&[(1, (0, 10))]).await;
1376        let file_len = file_contents.data.len() as u64;
1377
1378        let temp_dir = tempfile::tempdir().unwrap();
1379        let file_path = temp_dir.path().join("output.bin");
1380
1381        // Pre-create the file with a specific pattern.
1382        let pattern: Vec<u8> = (0..file_len).map(|i| (i % 251) as u8).collect();
1383        std::fs::write(&file_path, &pattern).unwrap();
1384
1385        // Write only the middle third.
1386        let start = file_len / 3;
1387        let end = 2 * file_len / 3;
1388        let range = FileRange::new(start, end);
1389
1390        reconstruct_range_to_file_path(&client, file_contents.file_hash, &file_path, range, test_config())
1391            .await
1392            .unwrap();
1393
1394        let result = std::fs::read(&file_path).unwrap();
1395
1396        // First and last thirds should still have the pattern.
1397        assert_eq!(&result[..start as usize], &pattern[..start as usize]);
1398        assert_eq!(&result[end as usize..], &pattern[end as usize..]);
1399
1400        // Middle third should have reconstructed data.
1401        assert_eq!(&result[start as usize..end as usize], &file_contents.data[start as usize..end as usize]);
1402    }
1403
1404    // ==================== Multi-Disjoint Range Tests (LocalClient) ====================
1405    //
1406    // These tests exercise complex disjoint range patterns through the LocalClient path
1407    // (no HTTP server), ensuring the reconstruction logic handles V2 multi-range
1408    // XorbBlocks correctly.
1409
1410    /// Single xorb with three disjoint chunk ranges.
1411    #[tokio::test]
1412    async fn test_triple_disjoint_ranges_full() {
1413        let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (1, (4, 6)), (1, (8, 10))]).await;
1414        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1415    }
1416
1417    /// Single xorb with three disjoint chunk ranges, partial byte range.
1418    #[tokio::test]
1419    async fn test_triple_disjoint_ranges_partial() {
1420        let (client, file_contents) = setup_test_file(&[(1, (0, 2)), (1, (4, 6)), (1, (8, 10))]).await;
1421        let file_len = file_contents.data.len() as u64;
1422        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1423        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
1424    }
1425
1426    /// Multiple xorbs, each with multiple disjoint ranges, interleaved.
1427    #[tokio::test]
1428    async fn test_multi_xorb_interleaved_disjoint() {
1429        let term_spec = &[
1430            (1, (0, 2)),
1431            (2, (0, 2)),
1432            (1, (4, 6)),
1433            (2, (4, 6)),
1434            (1, (8, 10)),
1435            (2, (8, 10)),
1436        ];
1437        let (client, file_contents) = setup_test_file(term_spec).await;
1438        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1439    }
1440
1441    /// Multiple xorbs with interleaved disjoint ranges, partial byte range.
1442    #[tokio::test]
1443    async fn test_multi_xorb_interleaved_disjoint_partial() {
1444        let term_spec = &[
1445            (1, (0, 2)),
1446            (2, (0, 2)),
1447            (1, (4, 6)),
1448            (2, (4, 6)),
1449            (1, (8, 10)),
1450            (2, (8, 10)),
1451        ];
1452        let (client, file_contents) = setup_test_file(term_spec).await;
1453        let file_len = file_contents.data.len() as u64;
1454        let range = FileRange::new(file_len / 3, file_len * 2 / 3);
1455        reconstruct_and_verify_range(&client, &file_contents, range, test_config()).await;
1456    }
1457
1458    /// Single xorb with four disjoint ranges (many gaps).
1459    #[tokio::test]
1460    async fn test_four_disjoint_ranges() {
1461        let term_spec = &[(1, (0, 2)), (1, (4, 6)), (1, (8, 10)), (1, (12, 14))];
1462        let (client, file_contents) = setup_test_file(term_spec).await;
1463        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1464    }
1465
1466    /// Mix of contiguous and disjoint ranges from the same xorb.
1467    #[tokio::test]
1468    async fn test_mixed_contiguous_and_disjoint() {
1469        let term_spec = &[
1470            (1, (0, 3)),  // contiguous block
1471            (1, (3, 5)),  // continues contiguously
1472            (1, (8, 10)), // gap, then disjoint
1473        ];
1474        let (client, file_contents) = setup_test_file(term_spec).await;
1475        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1476    }
1477
1478    /// Disjoint ranges across three xorbs with a complex access pattern.
1479    #[tokio::test]
1480    async fn test_complex_three_xorb_disjoint() {
1481        let term_spec = &[
1482            (1, (0, 2)),
1483            (2, (0, 3)),
1484            (3, (2, 5)),
1485            (1, (5, 8)),
1486            (2, (6, 8)),
1487            (3, (0, 2)),
1488        ];
1489        let (client, file_contents) = setup_test_file(term_spec).await;
1490        reconstruct_and_verify_full(&client, &file_contents, test_config()).await;
1491    }
1492
1493    /// LocalClient with max_ranges_per_fetch=2 (tests V2 response splitting without HTTP).
1494    #[tokio::test]
1495    async fn test_local_client_max_ranges_2_disjoint() {
1496        let client = LocalClient::temporary(XetContext::default().unwrap()).await.unwrap();
1497        client.set_max_ranges_per_fetch(2);
1498
1499        let term_spec = &[(1, (0, 2)), (1, (4, 6)), (1, (8, 10)), (1, (12, 14))];
1500        let file_contents = client.upload_random_file(term_spec, TEST_CHUNK_SIZE).await.unwrap();
1501
1502        let config = test_config();
1503        let result = reconstruct_to_vec(&client, file_contents.file_hash, None, &config, None)
1504            .await
1505            .unwrap();
1506        assert_eq!(result, file_contents.data.as_ref());
1507    }
1508
1509    /// LocalClient with max_ranges_per_fetch=1 (every range gets its own fetch entry).
1510    #[tokio::test]
1511    async fn test_local_client_max_ranges_1_multi_xorb() {
1512        let client = LocalClient::temporary(XetContext::default().unwrap()).await.unwrap();
1513        client.set_max_ranges_per_fetch(1);
1514
1515        let term_spec = &[(1, (0, 2)), (2, (0, 2)), (1, (4, 6)), (2, (4, 6))];
1516        let file_contents = client.upload_random_file(term_spec, TEST_CHUNK_SIZE).await.unwrap();
1517
1518        let config = test_config();
1519        let result = reconstruct_to_vec(&client, file_contents.file_hash, None, &config, None)
1520            .await
1521            .unwrap();
1522        assert_eq!(result, file_contents.data.as_ref());
1523    }
1524
1525    // ==================== Cancellation Flag Tests ====================
1526
1527    mod cancellation_tests {
1528        use tokio_util::sync::CancellationToken;
1529
1530        use super::*;
1531
1532        #[tokio::test]
1533        async fn test_cancellation_token_before_start() {
1534            let (client, file_contents) = setup_test_file(&[(1, (0, 3))]).await;
1535            let config = test_config();
1536
1537            let token = CancellationToken::new();
1538            token.cancel();
1539            let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
1540            let writer = StaticCursorWriter(buffer.clone());
1541
1542            let bytes_written = FileReconstructor::new(
1543                &XetContext::default().unwrap(),
1544                &(client.clone() as Arc<dyn Client>),
1545                file_contents.file_hash,
1546            )
1547            .with_config(&config)
1548            .with_cancellation_token(token)
1549            .reconstruct_to_writer(writer)
1550            .await
1551            .unwrap();
1552
1553            assert_eq!(bytes_written, 0);
1554        }
1555
1556        /// A writer that cancels a token after a certain number of writes,
1557        /// used to deterministically test mid-reconstruction cancellation.
1558        struct CancellingWriter {
1559            buffer: Arc<std::sync::Mutex<Vec<u8>>>,
1560            cancel_token: CancellationToken,
1561            write_count: AtomicUsize,
1562            cancel_after_writes: usize,
1563        }
1564
1565        impl Write for CancellingWriter {
1566            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1567                let n = self.buffer.lock().unwrap().write(buf)?;
1568                let count = self.write_count.fetch_add(1, Ordering::Relaxed) + 1;
1569                if count >= self.cancel_after_writes {
1570                    self.cancel_token.cancel();
1571                }
1572                Ok(n)
1573            }
1574
1575            fn flush(&mut self) -> std::io::Result<()> {
1576                Ok(())
1577            }
1578        }
1579
1580        #[tokio::test]
1581        async fn test_cancellation_token_during_reconstruction() {
1582            let term_spec: Vec<(u64, (u64, u64))> = (1..=10).map(|i| (i, (0, 5))).collect();
1583            let (client, file_contents) = setup_test_file(&term_spec).await;
1584            let config = test_config();
1585
1586            let token = CancellationToken::new();
1587            let buffer = Arc::new(std::sync::Mutex::new(Vec::new()));
1588
1589            let writer = CancellingWriter {
1590                buffer: buffer.clone(),
1591                cancel_token: token.clone(),
1592                write_count: AtomicUsize::new(0),
1593                cancel_after_writes: 1,
1594            };
1595
1596            // Use a tiny semaphore to force sequential term processing.
1597            let tiny_semaphore = AdjustableSemaphore::new(1, (1, 1));
1598
1599            let bytes_written = FileReconstructor::new(
1600                &XetContext::default().unwrap(),
1601                &(client.clone() as Arc<dyn Client>),
1602                file_contents.file_hash,
1603            )
1604            .with_config(&config)
1605            .with_cancellation_token(token)
1606            .with_buffer_semaphore(tiny_semaphore)
1607            .reconstruct_to_writer(writer)
1608            .await
1609            .unwrap();
1610
1611            // Verify cancellation returned Ok(0) and only partial data was written.
1612            assert_eq!(bytes_written, 0);
1613            let written = buffer.lock().unwrap().len();
1614            assert!(written < file_contents.data.len());
1615        }
1616
1617        #[tokio::test]
1618        async fn test_cancellation_token_not_set_completes_normally() {
1619            let (client, file_contents) = setup_test_file(&[(1, (0, 3)), (2, (0, 2))]).await;
1620            let config = test_config();
1621
1622            let token = CancellationToken::new();
1623            let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
1624            let writer = StaticCursorWriter(buffer.clone());
1625
1626            let bytes_written = FileReconstructor::new(
1627                &XetContext::default().unwrap(),
1628                &(client.clone() as Arc<dyn Client>),
1629                file_contents.file_hash,
1630            )
1631            .with_config(&config)
1632            .with_cancellation_token(token)
1633            .reconstruct_to_writer(writer)
1634            .await
1635            .unwrap();
1636
1637            assert_eq!(bytes_written, file_contents.data.len() as u64);
1638            assert_eq!(buffer.lock().unwrap().get_ref().clone(), file_contents.data);
1639        }
1640    }
1641
1642    // ==================== Multirange Fetching Tests (LocalClient) ====================
1643
1644    mod multirange_tests {
1645        use super::*;
1646
1647        fn with_multirange_config(enable: bool) -> XetContext {
1648            let mut config = xet_runtime::config::XetConfig::new();
1649            config.client.enable_multirange_fetching = enable;
1650            XetContext::with_config(config).unwrap()
1651        }
1652
1653        /// Exercises multiple disjoint-range scenarios through LocalClient with both
1654        /// enable_multirange_fetching=true and =false.
1655        #[test]
1656        fn test_multirange_local_client() {
1657            for enable in [false, true] {
1658                let ctx = with_multirange_config(enable);
1659                ctx.runtime
1660                    .bridge_sync(async move {
1661                        let scenarios: Vec<Vec<(u64, (u64, u64))>> = vec![
1662                            vec![(1, (0, 2)), (1, (4, 6)), (1, (8, 10))],
1663                            vec![
1664                                (1, (0, 2)),
1665                                (2, (0, 2)),
1666                                (1, (4, 6)),
1667                                (2, (4, 6)),
1668                                (1, (8, 10)),
1669                                (2, (8, 10)),
1670                            ],
1671                            vec![
1672                                (1, (0, 2)),
1673                                (2, (0, 3)),
1674                                (3, (2, 5)),
1675                                (1, (5, 8)),
1676                                (2, (6, 8)),
1677                                (3, (0, 2)),
1678                            ],
1679                        ];
1680                        let config = test_config();
1681                        for term_spec in &scenarios {
1682                            let (client, fc) = setup_test_file(term_spec).await;
1683                            reconstruct_and_verify_full(&client, &fc, config.clone()).await;
1684
1685                            let file_len = fc.data.len() as u64;
1686                            let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1687                            reconstruct_and_verify_range(&client, &fc, range, config.clone()).await;
1688                        }
1689                    })
1690                    .unwrap();
1691            }
1692        }
1693
1694        /// LocalClient with max_ranges_per_fetch constraint, both enable settings.
1695        #[test]
1696        fn test_multirange_max_ranges() {
1697            for enable in [false, true] {
1698                let ctx = with_multirange_config(enable);
1699                ctx.runtime
1700                    .bridge_sync(async {
1701                        let client = LocalClient::temporary(XetContext::default().unwrap()).await.unwrap();
1702                        client.set_max_ranges_per_fetch(2);
1703
1704                        let term_spec = &[(1, (0, 2)), (1, (4, 6)), (1, (8, 10)), (1, (12, 14))];
1705                        let fc = client.upload_random_file(term_spec, TEST_CHUNK_SIZE).await.unwrap();
1706
1707                        let config = test_config();
1708                        let result = reconstruct_to_vec(&client, fc.file_hash, None, &config, None).await.unwrap();
1709                        assert_eq!(result, fc.data.as_ref());
1710                    })
1711                    .unwrap();
1712            }
1713        }
1714    }
1715
1716    // ==================== Server-dependent tests (require simulation feature) ====================
1717    #[cfg(feature = "simulation")]
1718    mod server_tests {
1719        use super::*;
1720
1721        // ==================== V1 Fallback Tests ====================
1722        //
1723        // These tests use LocalTestServer with V2 disabled to verify that
1724        // reconstruction works correctly when the client falls back from V2 to V1.
1725
1726        /// Helper to reconstruct through a LocalTestServer (RemoteClient HTTP path).
1727        async fn reconstruct_via_server(
1728            server: &xet_client::cas_client::LocalTestServer,
1729            file_hash: MerkleHash,
1730            byte_range: Option<FileRange>,
1731            config: &ReconstructionConfig,
1732        ) -> Result<Vec<u8>> {
1733            let buffer = Arc::new(std::sync::Mutex::new(Cursor::new(Vec::new())));
1734            let writer = StaticCursorWriter(buffer.clone());
1735
1736            let client: Arc<dyn Client> = server.remote_client().clone();
1737            let mut reconstructor =
1738                FileReconstructor::new(&XetContext::default().unwrap(), &client, file_hash).with_config(config);
1739
1740            if let Some(range) = byte_range {
1741                reconstructor = reconstructor.with_byte_range(range);
1742            }
1743
1744            reconstructor.reconstruct_to_writer(writer).await?;
1745
1746            let data = buffer.lock().unwrap().get_ref().clone();
1747            Ok(data)
1748        }
1749
1750        #[tokio::test]
1751        async fn test_v1_fallback_full_reconstruction() {
1752            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1753            let file_contents = server
1754                .remote_client()
1755                .upload_random_file(&[(1, (0, 3)), (2, (0, 2))], TEST_CHUNK_SIZE)
1756                .await
1757                .unwrap();
1758
1759            server.disable_v2_endpoints(404);
1760
1761            let config = test_config();
1762            let result = reconstruct_via_server(&server, file_contents.file_hash, None, &config)
1763                .await
1764                .unwrap();
1765            assert_eq!(result, file_contents.data.as_ref());
1766        }
1767
1768        #[tokio::test]
1769        async fn test_v1_fallback_partial_range() {
1770            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1771            let file_contents = server
1772                .remote_client()
1773                .upload_random_file(&[(1, (0, 5)), (2, (0, 3))], TEST_CHUNK_SIZE)
1774                .await
1775                .unwrap();
1776
1777            server.disable_v2_endpoints(404);
1778
1779            let file_len = file_contents.data.len() as u64;
1780            let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1781
1782            let config = test_config();
1783            let result = reconstruct_via_server(&server, file_contents.file_hash, Some(range), &config)
1784                .await
1785                .unwrap();
1786            assert_eq!(result, &file_contents.data[range.start as usize..range.end as usize]);
1787        }
1788
1789        #[tokio::test]
1790        async fn test_v1_fallback_non_contiguous_chunks() {
1791            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1792            let file_contents = server
1793                .remote_client()
1794                .upload_random_file(&[(1, (0, 2)), (1, (4, 6))], TEST_CHUNK_SIZE)
1795                .await
1796                .unwrap();
1797
1798            server.disable_v2_endpoints(404);
1799
1800            let config = test_config();
1801            let result = reconstruct_via_server(&server, file_contents.file_hash, None, &config)
1802                .await
1803                .unwrap();
1804            assert_eq!(result, file_contents.data.as_ref());
1805        }
1806
1807        #[tokio::test]
1808        async fn test_v1_fallback_multiple_xorbs() {
1809            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1810            let file_contents = server
1811                .remote_client()
1812                .upload_random_file(&[(1, (0, 2)), (2, (0, 3)), (3, (0, 2)), (1, (2, 4))], TEST_CHUNK_SIZE)
1813                .await
1814                .unwrap();
1815
1816            server.disable_v2_endpoints(404);
1817
1818            let config = test_config();
1819            let result = reconstruct_via_server(&server, file_contents.file_hash, None, &config)
1820                .await
1821                .unwrap();
1822            assert_eq!(result, file_contents.data.as_ref());
1823        }
1824
1825        /// V1 fallback with three disjoint ranges from the same xorb.
1826        #[tokio::test]
1827        async fn test_v1_fallback_triple_disjoint_ranges() {
1828            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1829            let file_contents = server
1830                .remote_client()
1831                .upload_random_file(&[(1, (0, 2)), (1, (4, 6)), (1, (8, 10))], TEST_CHUNK_SIZE)
1832                .await
1833                .unwrap();
1834
1835            server.disable_v2_endpoints(404);
1836
1837            let config = test_config();
1838            let result = reconstruct_via_server(&server, file_contents.file_hash, None, &config)
1839                .await
1840                .unwrap();
1841            assert_eq!(result, file_contents.data.as_ref());
1842        }
1843
1844        // ==================== Max Ranges Tests (via server) ====================
1845
1846        /// Helper to set up a server with max_ranges_per_fetch and reconstruct.
1847        async fn reconstruct_via_server_with_max_ranges(
1848            term_spec: &[(u64, (u64, u64))],
1849            max_ranges: usize,
1850            byte_range: Option<FileRange>,
1851        ) -> (Vec<u8>, RandomFileContents) {
1852            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1853            let file_contents = server
1854                .remote_client()
1855                .upload_random_file(term_spec, TEST_CHUNK_SIZE)
1856                .await
1857                .unwrap();
1858
1859            server.set_max_ranges_per_fetch(max_ranges);
1860
1861            let config = test_config();
1862            let result = reconstruct_via_server(&server, file_contents.file_hash, byte_range, &config)
1863                .await
1864                .unwrap();
1865            (result, file_contents)
1866        }
1867
1868        #[tokio::test]
1869        async fn test_max_ranges_simple() {
1870            let (result, file_contents) =
1871                reconstruct_via_server_with_max_ranges(&[(1, (0, 3)), (2, (0, 2))], 2, None).await;
1872            assert_eq!(result, file_contents.data.as_ref());
1873        }
1874
1875        #[tokio::test]
1876        async fn test_max_ranges_1_disjoint() {
1877            let (result, file_contents) =
1878                reconstruct_via_server_with_max_ranges(&[(1, (0, 2)), (1, (4, 6))], 1, None).await;
1879            assert_eq!(result, file_contents.data.as_ref());
1880        }
1881
1882        #[tokio::test]
1883        async fn test_max_ranges_2_triple_disjoint() {
1884            let (result, file_contents) =
1885                reconstruct_via_server_with_max_ranges(&[(1, (0, 2)), (1, (4, 6)), (1, (8, 10))], 2, None).await;
1886            assert_eq!(result, file_contents.data.as_ref());
1887        }
1888
1889        #[tokio::test]
1890        async fn test_max_ranges_2_multi_xorb_disjoint() {
1891            let term_spec = &[
1892                (1, (0, 2)),
1893                (2, (0, 2)),
1894                (1, (4, 6)),
1895                (2, (4, 6)),
1896                (1, (8, 10)),
1897                (2, (8, 10)),
1898            ];
1899            let (result, file_contents) = reconstruct_via_server_with_max_ranges(term_spec, 2, None).await;
1900            assert_eq!(result, file_contents.data.as_ref());
1901        }
1902
1903        #[tokio::test]
1904        async fn test_max_ranges_2_partial_range() {
1905            let term_spec = &[
1906                (1, (0, 3)),
1907                (2, (0, 2)),
1908                (1, (3, 5)),
1909                (3, (1, 4)),
1910                (2, (4, 6)),
1911                (1, (0, 2)),
1912            ];
1913            let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1914            let file_contents = server
1915                .remote_client()
1916                .upload_random_file(term_spec, TEST_CHUNK_SIZE)
1917                .await
1918                .unwrap();
1919
1920            server.set_max_ranges_per_fetch(2);
1921
1922            let file_len = file_contents.data.len() as u64;
1923            let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1924
1925            let config = test_config();
1926            let result = reconstruct_via_server(&server, file_contents.file_hash, Some(range), &config)
1927                .await
1928                .unwrap();
1929            assert_eq!(result, &file_contents.data[range.start as usize..range.end as usize]);
1930        }
1931
1932        // ==================== Multirange via Server ====================
1933
1934        fn with_multirange_config(enable: bool) -> XetContext {
1935            let mut config = xet_runtime::config::XetConfig::new();
1936            config.client.enable_multirange_fetching = enable;
1937            XetContext::with_config(config).unwrap()
1938        }
1939
1940        /// Exercises HTTP server path with full, max-ranges-split, and partial-range
1941        /// reconstruction, both enable_multirange_fetching values.
1942        #[test]
1943        fn test_multirange_via_server() {
1944            for enable in [false, true] {
1945                let ctx = with_multirange_config(enable);
1946                ctx.runtime
1947                    .bridge_sync(async {
1948                        let config = test_config();
1949
1950                        // Full reconstruction with disjoint ranges
1951                        let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1952                        let fc = server
1953                            .remote_client()
1954                            .upload_random_file(&[(1, (0, 2)), (1, (4, 6)), (1, (8, 10))], TEST_CHUNK_SIZE)
1955                            .await
1956                            .unwrap();
1957                        let result = reconstruct_via_server(&server, fc.file_hash, None, &config).await.unwrap();
1958                        assert_eq!(result, fc.data.as_ref());
1959
1960                        // Multi-xorb with max_ranges_per_fetch=2
1961                        let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1962                        let fc = server
1963                            .remote_client()
1964                            .upload_random_file(
1965                                &[(1, (0, 2)), (2, (0, 2)), (1, (4, 6)), (2, (4, 6)), (1, (8, 10))],
1966                                TEST_CHUNK_SIZE,
1967                            )
1968                            .await
1969                            .unwrap();
1970                        server.set_max_ranges_per_fetch(2);
1971                        let result = reconstruct_via_server(&server, fc.file_hash, None, &config).await.unwrap();
1972                        assert_eq!(result, fc.data.as_ref());
1973
1974                        // Partial byte range
1975                        let server = xet_client::cas_client::LocalTestServerBuilder::new().start().await;
1976                        let fc = server
1977                            .remote_client()
1978                            .upload_random_file(&[(1, (0, 3)), (2, (0, 2)), (1, (3, 5)), (2, (4, 6))], TEST_CHUNK_SIZE)
1979                            .await
1980                            .unwrap();
1981                        let file_len = fc.data.len() as u64;
1982                        let range = FileRange::new(file_len / 4, file_len * 3 / 4);
1983                        let result = reconstruct_via_server(&server, fc.file_hash, Some(range), &config)
1984                            .await
1985                            .unwrap();
1986                        assert_eq!(result, &fc.data[range.start as usize..range.end as usize]);
1987                    })
1988                    .unwrap();
1989            }
1990        }
1991    } // mod server_tests
1992}