Skip to main content

aria2_core/engine/
bt_piece_downloader.rs

1#![allow(clippy::empty_line_after_doc_comments)]
2
3use crate::engine::bt_upload_session::PieceDataProvider;
4use crate::engine::multi_file_layout::MultiFileLayout;
5use crate::error::{Aria2Error, FatalError, Result};
6use std::collections::{HashMap, HashSet};
7use std::time::Instant;
8
9/// Manages piece/block download operations for BitTorrent downloads.
10///
11/// This module encapsulates:
12/// - Piece selection and block request logic
13/// - Data verification and hash checking
14/// - Writing downloaded data to disk or multi-file layouts
15/// - File-backed piece provider for seeding phase
16///
17/// Extracted from BtDownloadCommand to follow single responsibility principle,
18/// mirroring original aria2 C++ architecture separation.
19// ======================================================================
20// Piece Download State Machine
21// ======================================================================
22
23/// Tracks per-block download state for a single piece
24///
25/// This state machine provides fine-grained tracking of block-level download progress,
26/// enabling detection of stalled downloads and intelligent re-request from different peers.
27///
28/// # Lifecycle
29///
30/// 1. **Created** when a piece is selected for download
31/// 2. **Blocks requested** via `mark_block_requested()`
32/// 3. **Blocks received** via `mark_block_received()` (updates last_activity)
33/// 4. **Stalled detection** via `is_stalled(timeout)` - if no activity for N seconds
34/// 5. **Complete** when all blocks received (`is_complete() == true`)
35///
36/// # Example
37///
38/// ```rust,no_run
39/// use aria2_core::engine::bt_piece_downloader::PieceDownloadState;
40///
41/// let mut state = PieceDownloadState::new(0, 262144, 16384); // piece 0, 256KB, 16KB blocks
42/// assert_eq!(state.total_blocks, 16); // 256KB / 16KB = 16 blocks
43/// assert!(!state.is_complete());
44///
45/// state.mark_block_requested(0);
46/// state.mark_block_received(0);
47/// assert_eq!(state.blocks_remaining(), 15);
48/// ```
49#[derive(Debug, Clone)]
50pub struct PieceDownloadState {
51    /// Index of this piece in the torrent
52    pub piece_index: u32,
53    /// Total number of blocks in this piece
54    pub total_blocks: u32,
55    /// Set of block indices that have been fully received
56    pub completed_blocks: HashSet<u32>,
57    /// Map of block_index → request_time for pending requests
58    pub requested_blocks: HashMap<u32, Instant>,
59    /// Timestamp of last successful block receive or cancellation
60    pub last_activity: Instant,
61}
62
63impl PieceDownloadState {
64    /// Create a new PieceDownloadState for the given piece parameters
65    ///
66    /// # Arguments
67    /// * `piece_index` - Index of the piece being tracked
68    /// * `piece_length` - Total length of this piece in bytes
69    /// * `block_size` - Size of each block (typically 16KB)
70    ///
71    /// # Returns
72    /// * Initialized state with no completed or requested blocks
73    pub fn new(piece_index: u32, piece_length: u32, block_size: u32) -> Self {
74        let total_blocks = if block_size > 0 {
75            piece_length.div_ceil(block_size)
76        } else {
77            0
78        };
79
80        Self {
81            piece_index,
82            total_blocks,
83            completed_blocks: HashSet::new(),
84            requested_blocks: HashMap::new(),
85            last_activity: Instant::now(),
86        }
87    }
88
89    /// Get number of blocks still needed to complete this piece
90    ///
91    /// # Returns
92    /// * Count of incomplete blocks (total - completed)
93    pub fn blocks_remaining(&self) -> usize {
94        (self.total_blocks as usize).saturating_sub(self.completed_blocks.len())
95    }
96
97    /// Check if all blocks have been received
98    ///
99    /// # Returns
100    /// * `true` if completed_blocks count >= total_blocks
101    pub fn is_complete(&self) -> bool {
102        self.completed_blocks.len() as u32 >= self.total_blocks
103    }
104
105    /// Check if the download appears stalled (no recent activity with pending requests)
106    ///
107    /// A piece is considered stalled if:
108    /// - It's not yet complete
109    /// - There are outstanding requested blocks
110    /// - No activity has occurred for longer than timeout_secs
111    ///
112    /// # Arguments
113    /// * `timeout_secs` - Number of seconds without activity to consider stalled
114    ///
115    /// # Returns
116    /// * `true` if the piece appears stuck and may need re-requesting
117    pub fn is_stalled(&self, timeout_secs: u64) -> bool {
118        !self.is_complete()
119            && self.last_activity.elapsed().as_secs() > timeout_secs
120            && !self.requested_blocks.is_empty()
121    }
122
123    /// Mark a block as requested (sent Request message to peer)
124    ///
125    /// Updates both the requested_blocks map and last_activity timestamp.
126    ///
127    /// # Arguments
128    /// * `block_index` - Index of the block within this piece
129    pub fn mark_block_requested(&mut self, block_index: u32) {
130        self.requested_blocks.insert(block_index, Instant::now());
131        self.last_activity = Instant::now();
132    }
133
134    /// Mark a block as received (got Piece message from peer)
135    ///
136    /// Moves the block from requested to completed and updates last_activity.
137    ///
138    /// # Arguments
139    /// * `block_index` - Index of the block within this piece
140    pub fn mark_block_received(&mut self, block_index: u32) {
141        self.completed_blocks.insert(block_index);
142        self.requested_blocks.remove(&block_index);
143        self.last_activity = Instant::now();
144    }
145
146    /// Mark a block as cancelled (sent Cancel message or gave up)
147    ///
148    /// Removes the block from requested but does NOT add to completed.
149    /// Does not update last_activity (cancellation isn't progress).
150    ///
151    /// # Arguments
152    /// * `block_index` - Index of the block within this piece
153    pub fn mark_block_cancelled(&mut self, block_index: u32) {
154        self.requested_blocks.remove(&block_index);
155    }
156
157    /// Get completion percentage (0.0 to 100.0)
158    ///
159    /// # Returns
160    /// * Percentage of blocks that have been received
161    pub fn progress_percent(&self) -> f64 {
162        if self.total_blocks == 0 {
163            return 100.0;
164        }
165        (self.completed_blocks.len() as f64 / self.total_blocks as f64) * 100.0
166    }
167}
168
169// ======================================================================
170// File-Backed Piece Provider
171// ======================================================================
172
173/// Provides piece data from local files, used during seeding phase.
174///
175/// Supports both single-file and multi-file torrent layouts.
176pub struct FileBackedPieceProvider {
177    file_path: std::path::PathBuf,
178    piece_length: u32,
179    num_pieces: u32,
180    multi_file_layout: Option<MultiFileLayout>,
181}
182
183impl FileBackedPieceProvider {
184    pub fn new(
185        file_path: std::path::PathBuf,
186        piece_length: u32,
187        num_pieces: u32,
188        multi_file_layout: Option<MultiFileLayout>,
189    ) -> Self {
190        Self {
191            file_path,
192            piece_length,
193            num_pieces,
194            multi_file_layout,
195        }
196    }
197}
198
199impl PieceDataProvider for FileBackedPieceProvider {
200    fn get_piece_data(&self, piece_index: u32, offset: u32, length: u32) -> Option<Vec<u8>> {
201        use std::io::SeekFrom;
202        use tokio::fs::File;
203        use tokio::io::{AsyncReadExt, AsyncSeekExt};
204
205        let read_op =
206            move |file_path: std::path::PathBuf, seek_pos: u64, len: u32| -> Option<Vec<u8>> {
207                let rt = match tokio::runtime::Handle::try_current() {
208                    Ok(handle) => handle,
209                    Err(_) => {
210                        let rt = tokio::runtime::Runtime::new().ok()?;
211                        return rt.block_on(async {
212                            let mut f = File::open(&file_path).await.ok()?;
213                            f.seek(SeekFrom::Start(seek_pos)).await.ok()?;
214                            let mut buf = vec![0u8; len as usize];
215                            f.read_exact(&mut buf).await.ok()?;
216                            Some(buf)
217                        });
218                    }
219                };
220                tokio::task::block_in_place(|| {
221                    rt.block_on(async {
222                        let mut f = File::open(&file_path).await.ok()?;
223                        f.seek(SeekFrom::Start(seek_pos)).await.ok()?;
224                        let mut buf = vec![0u8; len as usize];
225                        f.read_exact(&mut buf).await.ok()?;
226                        Some(buf)
227                    })
228                })
229            };
230
231        if let Some(ref layout) = self.multi_file_layout {
232            let global_start = piece_index as u64 * layout.piece_length() as u64 + offset as u64;
233
234            if global_start >= layout.total_size() {
235                return None;
236            }
237
238            let actual_length = (length as u64).min(layout.total_size() - global_start) as u32;
239            let mut result = Vec::with_capacity(actual_length as usize);
240            let mut current_global = global_start;
241            let mut remaining = actual_length as u64;
242
243            while remaining > 0 {
244                let current_piece_idx = (current_global / layout.piece_length() as u64) as u32;
245                let current_offset_in_piece =
246                    (current_global % layout.piece_length() as u64) as u32;
247
248                let (file_idx, file_offset) =
249                    layout.resolve_file_offset(current_piece_idx, current_offset_in_piece)?;
250                let file_path = layout.file_absolute_path(file_idx)?.to_path_buf();
251
252                let file_info = layout.get_file_info(file_idx)?;
253                let file_end = file_info.start_piece as u64 * layout.piece_length() as u64
254                    + file_info.start_offset_in_piece as u64
255                    + file_info.length;
256
257                let bytes_available_in_file = file_end - current_global;
258                let bytes_to_read = remaining.min(bytes_available_in_file) as u32;
259
260                let data = read_op(file_path.clone(), file_offset, bytes_to_read)?;
261                result.extend_from_slice(&data);
262                current_global += data.len() as u64;
263                remaining -= data.len() as u64;
264            }
265
266            Some(result)
267        } else {
268            let file_pos = piece_index as u64 * self.piece_length as u64 + offset as u64;
269            read_op(self.file_path.clone(), file_pos, length)
270        }
271    }
272
273    fn has_piece(&self, _piece_index: u32) -> bool {
274        true
275    }
276
277    fn num_pieces(&self) -> u32 {
278        self.num_pieces
279    }
280}
281
282// ======================================================================
283// Multi-File Writer
284// ======================================================================
285
286/// Coalesced write entry: tracks (file_index, file_offset, data) for batched I/O.
287///
288/// Adjacent writes to the same file within [`COALESCE_GAP`] bytes are merged
289/// into a single larger write, reducing the number of `seek` + `write_all`
290/// syscalls.
291struct CoalescedWrite {
292    file_idx: usize,
293    file_offset: u64,
294    data: Vec<u8>,
295}
296
297/// Maximum gap (in bytes) between two writes to the same file that will still
298/// be coalesced into a single write operation.  Gaps are zero-filled so that
299/// the resulting sparse region is correct on disk.
300const COALESCE_GAP: u64 = 4096;
301
302/// Writes a completed piece's data across multiple files in a multi-file torrent.
303///
304/// Handles cross-file boundary cases where a single piece spans multiple files.
305/// Each file is opened once, written to at the correct offsets, then flushed.
306///
307/// # Arguments
308/// * `layout` - The multi-file layout defining file boundaries
309/// * `piece_idx` - Index of the piece being written
310/// * `piece_data` - Complete piece data to write
311/// * `_piece_length` - Standard piece length (reserved for future use)
312///
313/// # Errors
314/// Returns error if file open, seek, write, or flush operations fail.
315pub async fn write_piece_to_multi_files(
316    layout: &MultiFileLayout,
317    piece_idx: u32,
318    piece_data: &[u8],
319    _piece_length: u32,
320) -> Result<()> {
321    use tokio::io::{AsyncSeekExt, AsyncWriteExt};
322
323    let mut file_writers: HashMap<usize, tokio::fs::File> = HashMap::new();
324
325    let mut data_offset = 0usize;
326    while data_offset < piece_data.len() {
327        let piece_offset = data_offset as u32;
328
329        if let Some((file_idx, file_offset)) = layout.resolve_file_offset(piece_idx, piece_offset) {
330            let file_path = layout
331                .file_absolute_path(file_idx)
332                .ok_or_else(|| {
333                    Aria2Error::Fatal(FatalError::Config("invalid file index".to_string()))
334                })?
335                .to_path_buf();
336
337            if let std::collections::hash_map::Entry::Vacant(e) = file_writers.entry(file_idx) {
338                let f = tokio::fs::OpenOptions::new()
339                    .create(true)
340                    .truncate(true)
341                    .write(true)
342                    .open(&file_path)
343                    .await
344                    .map_err(|e| {
345                        Aria2Error::Fatal(FatalError::Config(format!("open failed: {}", e)))
346                    })?;
347                e.insert(f);
348            }
349
350            let file_info = layout.get_file_info(file_idx).ok_or_else(|| {
351                Aria2Error::Fatal(FatalError::Config("invalid file index".to_string()))
352            })?;
353
354            let bytes_available_in_file = file_info.length.saturating_sub(file_offset);
355            let bytes_remaining_in_piece = (piece_data.len() - data_offset) as u64;
356            let write_len = bytes_available_in_file.min(bytes_remaining_in_piece) as usize;
357
358            if write_len == 0 {
359                break;
360            }
361
362            let chunk = &piece_data[data_offset..data_offset + write_len];
363
364            let writer = file_writers.get_mut(&file_idx).unwrap();
365            writer
366                .seek(std::io::SeekFrom::Start(file_offset))
367                .await
368                .map_err(|e| {
369                    Aria2Error::Fatal(FatalError::Config(format!("seek failed: {}", e)))
370                })?;
371            writer.write_all(chunk).await.map_err(|e| {
372                Aria2Error::Fatal(FatalError::Config(format!("write failed: {}", e)))
373            })?;
374
375            data_offset += write_len;
376        } else {
377            break;
378        }
379    }
380
381    for (_, mut f) in file_writers {
382        f.flush()
383            .await
384            .map_err(|e| Aria2Error::Fatal(FatalError::Config(format!("flush failed: {}", e))))?;
385    }
386
387    Ok(())
388}
389
390// ======================================================================
391// Coalesced Multi-File Writer  (Phase 14 / Task I4)
392// ======================================================================
393
394/// Writes a completed piece's data across multiple files using **coalesced
395/// writes** to reduce the number of `seek` + `write` syscalls.
396///
397/// # Algorithm
398///
399/// 1. **Collect** – iterate over `piece_data`, resolve each byte-range to
400///    `(file_idx, file_offset)`, and push a raw write entry.
401/// 2. **Sort** – order entries by `(file_idx, file_offset)` so that
402///    adjacent regions are neighbours.
403/// 3. **Coalesce** – merge consecutive writes to the **same file** whose
404///    start offset is within [`COALESCE_GAP`] bytes of the previous write's
405///    end.  Any gap is zero-filled (sparse region).
406/// 4. **Execute** – open each unique file **once**, seek + write_all per
407///    coalesced entry, then flush.
408///
409/// # When to use
410///
411/// Prefer this function over [`write_piece_to_multi_files`] for production
412/// downloads where a piece may span many files or many small write
413/// operations would otherwise occur.  The original function is retained for
414/// reference and as a simpler implementation.
415///
416/// # Arguments
417/// * `layout`      – The multi-file layout defining file boundaries.
418/// * `piece_idx`   – Index of the piece being written.
419/// * `piece_data`  – Complete piece data to write.
420/// * `_piece_length` – Standard piece length (reserved for future use).
421pub async fn write_piece_to_multi_files_coalesced(
422    layout: &MultiFileLayout,
423    piece_idx: u32,
424    piece_data: &[u8],
425    _piece_length: u32,
426) -> Result<()> {
427    use tokio::io::{AsyncSeekExt, AsyncWriteExt};
428
429    // ------------------------------------------------------------------
430    // Phase 1: Collect all raw write operations
431    // ------------------------------------------------------------------
432    let mut raw_writes: Vec<(usize, u64, Vec<u8>)> = Vec::new();
433    let mut data_offset = 0usize;
434
435    while data_offset < piece_data.len() {
436        let piece_offset = data_offset as u32;
437        if let Some((file_idx, file_offset)) = layout.resolve_file_offset(piece_idx, piece_offset) {
438            let file_info = layout.get_file_info(file_idx).ok_or_else(|| {
439                Aria2Error::Fatal(FatalError::Config("invalid file index".to_string()))
440            })?;
441
442            let bytes_available = file_info.length.saturating_sub(file_offset);
443            let bytes_remaining = (piece_data.len() - data_offset) as u64;
444            let write_len = bytes_available.min(bytes_remaining) as usize;
445
446            if write_len > 0 {
447                raw_writes.push((
448                    file_idx,
449                    file_offset,
450                    piece_data[data_offset..data_offset + write_len].to_vec(),
451                ));
452                data_offset += write_len;
453            } else {
454                break;
455            }
456        } else {
457            break;
458        }
459    }
460
461    // ------------------------------------------------------------------
462    // Phase 2: Sort by (file_idx, file_offset)
463    // ------------------------------------------------------------------
464    raw_writes.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
465
466    // ------------------------------------------------------------------
467    // Phase 3: Coalesce adjacent writes within COALESCE_GAP
468    // ------------------------------------------------------------------
469    let mut coalesced: Vec<CoalescedWrite> = Vec::new();
470
471    for (file_idx, file_offset, data) in raw_writes {
472        if let Some(last) = coalesced.last_mut() {
473            let last_end = last.file_offset + last.data.len() as u64;
474            if last.file_idx == file_idx && file_offset <= last_end + COALESCE_GAP {
475                // Extend or gap-fill then extend
476                if file_offset > last_end {
477                    // Fill gap with zeros (sparse region)
478                    last.data
479                        .extend_from_slice(&vec![0u8; (file_offset - last_end) as usize]);
480                }
481                last.data.extend_from_slice(&data);
482                continue;
483            }
484        }
485        coalesced.push(CoalescedWrite {
486            file_idx,
487            file_offset,
488            data,
489        });
490    }
491
492    // ------------------------------------------------------------------
493    // Phase 4: Execute coalesced writes (one open per unique file)
494    // ------------------------------------------------------------------
495    let mut file_writers: HashMap<usize, tokio::fs::File> = HashMap::new();
496
497    for cw in &coalesced {
498        let file_path = layout
499            .file_absolute_path(cw.file_idx)
500            .ok_or_else(|| Aria2Error::Fatal(FatalError::Config("invalid file index".to_string())))?
501            .to_path_buf();
502
503        let writer = match file_writers.entry(cw.file_idx) {
504            std::collections::hash_map::Entry::Vacant(e) => {
505                let f = tokio::fs::OpenOptions::new()
506                    .create(true)
507                    .truncate(true)
508                    .write(true)
509                    .open(&file_path)
510                    .await
511                    .map_err(|e| {
512                        Aria2Error::Fatal(FatalError::Config(format!("open failed: {}", e)))
513                    })?;
514                e.insert(f)
515            }
516            std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
517        };
518
519        writer
520            .seek(std::io::SeekFrom::Start(cw.file_offset))
521            .await
522            .map_err(|e| Aria2Error::Fatal(FatalError::Config(format!("seek failed: {}", e))))?;
523        writer
524            .write_all(&cw.data)
525            .await
526            .map_err(|e| Aria2Error::Fatal(FatalError::Config(format!("write failed: {}", e))))?;
527    }
528
529    for (_, mut f) in file_writers {
530        f.flush()
531            .await
532            .map_err(|e| Aria2Error::Fatal(FatalError::Config(format!("flush failed: {}", e))))?;
533    }
534
535    Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_piece_download_state_creation() {
544        // Test with standard 256KB piece and 16KB blocks
545        let state = PieceDownloadState::new(0, 262144, 16384);
546        assert_eq!(state.piece_index, 0);
547        assert_eq!(state.total_blocks, 16); // 262144 / 16384 = 16
548        assert!(state.completed_blocks.is_empty());
549        assert!(state.requested_blocks.is_empty());
550        assert!(!state.is_complete());
551        assert_eq!(state.blocks_remaining(), 16);
552        assert_eq!(state.progress_percent(), 0.0);
553    }
554
555    #[test]
556    fn test_piece_download_state_partial_last_block() {
557        // Test with piece that doesn't divide evenly
558        let state = PieceDownloadState::new(5, 20000, 16384);
559        assert_eq!(state.total_blocks, 2); // (20000 + 16384 - 1) / 16384 = 2
560        assert_eq!(state.blocks_remaining(), 2);
561    }
562
563    #[test]
564    fn test_piece_download_state_zero_block_size() {
565        // Edge case: zero block size should result in 0 total blocks
566        let state = PieceDownloadState::new(0, 100000, 0);
567        assert_eq!(state.total_blocks, 0);
568        assert!(state.is_complete()); // 0 blocks means "complete"
569    }
570
571    #[test]
572    fn test_mark_block_requested() {
573        let mut state = PieceDownloadState::new(0, 32768, 16384);
574
575        state.mark_block_requested(0);
576        assert_eq!(state.requested_blocks.len(), 1);
577        assert!(state.requested_blocks.contains_key(&0));
578        assert_eq!(state.blocks_remaining(), 2); // Still need all blocks
579
580        state.mark_block_requested(1);
581        assert_eq!(state.requested_blocks.len(), 2);
582    }
583
584    #[test]
585    fn test_mark_block_received() {
586        let mut state = PieceDownloadState::new(0, 32768, 16384);
587
588        // Request then receive block 0
589        state.mark_block_requested(0);
590        state.mark_block_received(0);
591
592        assert!(state.completed_blocks.contains(&0));
593        assert!(!state.requested_blocks.contains_key(&0));
594        assert_eq!(state.blocks_remaining(), 1);
595        assert_eq!(state.progress_percent(), 50.0);
596    }
597
598    #[test]
599    fn test_mark_block_cancelled() {
600        let mut state = PieceDownloadState::new(0, 32768, 16384);
601
602        state.mark_block_requested(0);
603        state.mark_block_cancelled(0);
604
605        assert!(!state.completed_blocks.contains(&0));
606        assert!(!state.requested_blocks.contains_key(&0));
607        assert_eq!(state.blocks_remaining(), 2); // Still need this block
608    }
609
610    #[test]
611    fn test_is_complete_all_blocks_received() {
612        let mut state = PieceDownloadState::new(0, 49152, 16384); // 3 blocks
613
614        for i in 0..3 {
615            state.mark_block_requested(i);
616            state.mark_block_received(i);
617        }
618
619        assert!(state.is_complete());
620        assert_eq!(state.blocks_remaining(), 0);
621        assert_eq!(state.progress_percent(), 100.0);
622    }
623
624    #[test]
625    fn test_is_stalled_with_pending_requests() {
626        let mut state = PieceDownloadState::new(0, 32768, 16384);
627
628        state.mark_block_requested(0);
629        state.mark_block_requested(1);
630
631        // Should not be stalled immediately
632        assert!(!state.is_stalled(30));
633
634        // Simulate time passing by checking with very small timeout
635        // In practice, this would require mocking time or using a very long timeout
636        // For now, just verify the logic structure is correct
637        assert!(state.requested_blocks.len() == 2);
638        assert!(!state.is_complete());
639    }
640
641    #[test]
642    fn test_is_not_stalled_when_no_pending_requests() {
643        let mut state = PieceDownloadState::new(0, 32768, 16384);
644
645        state.mark_block_requested(0);
646        state.mark_block_cancelled(0);
647
648        // No pending requests → not stalled even if no activity
649        assert!(!state.is_stalled(0)); // Even with 0 timeout
650    }
651
652    #[test]
653    fn test_progress_percent_calculation() {
654        let mut state = PieceDownloadState::new(0, 65536, 16384); // 4 blocks
655
656        assert_eq!(state.progress_percent(), 0.0);
657
658        state.mark_block_requested(0);
659        state.mark_block_received(0);
660        assert_eq!(state.progress_percent(), 25.0);
661
662        state.mark_block_requested(1);
663        state.mark_block_received(1);
664        assert_eq!(state.progress_percent(), 50.0);
665
666        state.mark_block_requested(2);
667        state.mark_block_received(2);
668        assert_eq!(state.progress_percent(), 75.0);
669
670        state.mark_block_requested(3);
671        state.mark_block_received(3);
672        assert_eq!(state.progress_percent(), 100.0);
673    }
674
675    #[test]
676    fn test_state_lifecycle_full_cycle() {
677        // Complete lifecycle: create → request → receive → complete
678        let mut state = PieceDownloadState::new(10, 262144, 16384);
679
680        // Initial state
681        assert_eq!(state.piece_index, 10);
682        assert_eq!(state.total_blocks, 16);
683        assert!(!state.is_complete());
684
685        // Request some blocks
686        for i in 0..5 {
687            state.mark_block_requested(i);
688        }
689        assert_eq!(state.requested_blocks.len(), 5);
690
691        // Receive first 3
692        for i in 0..3 {
693            state.mark_block_received(i);
694        }
695        assert_eq!(state.completed_blocks.len(), 3);
696        assert_eq!(state.requested_blocks.len(), 2); // 4,5 still pending
697        assert_eq!(state.progress_percent(), 18.75); // 3/16 = 18.75%
698
699        // Cancel remaining requested
700        state.mark_block_cancelled(3);
701        state.mark_block_cancelled(4);
702        state.mark_block_cancelled(5);
703        assert_eq!(state.requested_blocks.len(), 0);
704
705        // Continue to completion
706        for i in 3..16 {
707            state.mark_block_requested(i);
708            state.mark_block_received(i);
709        }
710
711        assert!(state.is_complete());
712        assert_eq!(state.progress_percent(), 100.0);
713    }
714}