Skip to main content

irontide_engine/
torrent.rs

1// M175: TorrentActor handles piece arithmetic (bounded by num_pieces: u32),
2// peer counters (bounded by max_peers_per_torrent), time deltas vs Instants
3// captured during the actor's lifetime, and qBt DTO conversions where the
4// wire format dictates narrower integer types. Per-site rationale carried
5// forward from M175 plan; review at a single grep target via
6// `grep "M175" crates/irontide-session/src/torrent.rs`.
7#![allow(
8    clippy::cast_possible_truncation,
9    clippy::cast_precision_loss,
10    clippy::cast_possible_wrap,
11    clippy::cast_sign_loss,
12    clippy::unchecked_time_subtraction,
13    reason = "M175: piece/peer arithmetic bounded by num_pieces/max_peers (u32); time deltas use post-init Instants; qBt DTOs follow wire-format integer widths"
14)]
15
16//! `TorrentActor` (single-owner event loop) and `TorrentHandle` (cloneable public API).
17//!
18//! The actor owns all per-torrent state (chunk tracking, piece selection, choking,
19//! peer management) and communicates with spawned `PeerTasks` via channels.
20//! The handle is a thin wrapper around an mpsc sender.
21
22use std::collections::{BTreeSet, HashMap, HashSet};
23use std::net::SocketAddr;
24
25use rustc_hash::FxHashMap;
26use std::sync::Arc;
27use std::sync::atomic::AtomicU32;
28use std::time::{Duration, Instant};
29
30use bytes::Bytes;
31use tokio::sync::{broadcast, mpsc, oneshot};
32use tracing::{Instrument, debug, info, trace, warn};
33
34use crate::alert::{Alert, AlertKind, post_alert};
35use crate::disk::{DiskHandle, DiskJobFlags, DiskManagerHandle};
36use crate::piece_reservation::{
37    AtomicPieceStates, BlockMaps, PieceOrderMap, PieceTracker, StealCandidates,
38};
39
40use irontide_core::{
41    DEFAULT_CHUNK_SIZE, FilePriority, Id20, Lengths, Magnet, PeerId, TorrentMetaV1,
42    torrent_from_bytes,
43};
44// M173 Lane B (B6): DhtHandle is no longer imported here — TorrentActor
45// uses irontide_dht::DhtReceiver via the DhtBroadcast pattern instead.
46// `current_dht()`/`current_dht_v6()` helpers return Option<DhtHandle>
47// from the receiver and use the fully-qualified path.
48use irontide_storage::{Bitfield, ChunkTracker, MemoryStorage, TorrentStorage};
49
50use crate::choker::{Choker, PeerInfo as ChokerPeerInfo};
51use crate::end_game::EndGame;
52use crate::metadata::MetadataDownloader;
53use crate::peer_adder::{self, ConnectPeer};
54use crate::peer_state::{PeerSource, PeerState};
55use crate::tracker_manager::TrackerManager;
56use crate::types::{
57    PartialPieceInfo, PeerCommand, PeerEvent, PeerInfo, TorrentCommand, TorrentConfig,
58    TorrentState, TorrentStats,
59};
60
61/// Shared global rate limiter bucket.
62pub(crate) type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
63
64/// Tribool result for piece hash verification in hybrid torrents.
65///
66/// Mirrors libtorrent-rasterbar's `boost::tribool` approach for dual-hash
67/// verification. `NotApplicable` covers cases where verification cannot run
68/// (e.g. missing hash picker, disk error before any block is checked).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(crate) enum HashResult {
71    /// All hashes matched.
72    Passed,
73    /// At least one hash did not match.
74    Failed,
75    /// Verification could not be performed (missing state / deferred).
76    NotApplicable,
77}
78
79/// Relocate torrent files from `src_base` to `dst_base`.
80///
81/// For each file, tries `rename` first (fast, same-filesystem), then falls
82/// back to copy + delete (cross-filesystem). Creates parent directories as
83/// needed. Returns error on the first failure.
84pub(crate) fn relocate_files(
85    src_base: &std::path::Path,
86    dst_base: &std::path::Path,
87    file_paths: &[std::path::PathBuf],
88) -> std::io::Result<()> {
89    for rel_path in file_paths {
90        let src = src_base.join(rel_path);
91        let dst = dst_base.join(rel_path);
92
93        if !src.exists() {
94            // File may not exist yet (e.g., not downloaded)
95            continue;
96        }
97
98        if let Some(parent) = dst.parent() {
99            std::fs::create_dir_all(parent)?;
100        }
101
102        // Try rename first (O(1) on same filesystem)
103        if std::fs::rename(&src, &dst).is_err() {
104            // Cross-filesystem: copy + delete
105            std::fs::copy(&src, &dst)?;
106            std::fs::remove_file(&src)?;
107        }
108    }
109
110    // Try to remove empty parent directories from source
111    // (best-effort, ignore errors)
112    for rel_path in file_paths {
113        let mut dir = src_base.join(rel_path);
114        dir.pop(); // get parent dir
115        while dir != *src_base {
116            if std::fs::remove_dir(&dir).is_err() {
117                break; // not empty or other error
118            }
119            dir.pop();
120        }
121    }
122
123    Ok(())
124}
125
126/// M224: Initial regular-unchoke slot count for a fresh `Choker`. Returns
127/// the cap from `Settings.max_uploads_per_torrent` when `n >= 1`, or the
128/// historical default of 4 when `n == -1` ("unlimited").
129pub(crate) fn initial_unchoke_slots(max_uploads_per_torrent: i32) -> usize {
130    if max_uploads_per_torrent >= 1 {
131        max_uploads_per_torrent as usize
132    } else {
133        4
134    }
135}
136
137/// Current time as POSIX seconds (0 on clock error).
138pub(crate) fn now_unix() -> i64 {
139    std::time::SystemTime::now()
140        .duration_since(std::time::UNIX_EPOCH)
141        .map_or(0, |d| d.as_secs() as i64)
142}
143
144/// M112: Cooldown period between holepunch attempts to the same address.
145pub(crate) const HOLEPUNCH_COOLDOWN: Duration = Duration::from_mins(2);
146
147/// M112: Maximum number of tracked holepunch cooldown entries to prevent unbounded growth.
148pub(crate) const HOLEPUNCH_MAX_TRACKED: usize = 256;
149
150/// M190: Maximum rendezvous requests per peer within the relay rate window.
151pub(crate) const HOLEPUNCH_RELAY_MAX_PER_WINDOW: u32 = 5;
152
153/// M190: Duration of the relay rate-limit window.
154pub(crate) const HOLEPUNCH_RELAY_WINDOW: Duration = Duration::from_secs(30);
155
156/// Returns true if the disconnect reason suggests the peer is behind NAT
157/// and a holepunch attempt might succeed.
158pub(crate) fn should_attempt_holepunch(reason: &str) -> bool {
159    // Don't re-attempt holepunch for failures from a previous holepunch attempt
160    if reason.contains("holepunch") {
161        return false;
162    }
163    reason.contains("refused")
164        || reason.contains("timed out")
165        || reason.contains("Connection reset")
166        || reason.contains("connection reset")
167}
168
169/// Cloneable handle for interacting with a running torrent.
170#[derive(Clone)]
171pub struct TorrentHandle {
172    pub cmd_tx: mpsc::Sender<TorrentCommand>,
173}
174
175impl TorrentHandle {
176    /// Create a torrent session from parsed .torrent metadata.
177    ///
178    /// Spawns the actor event loop and returns a handle for sending commands.
179    ///
180    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
181    /// from the session-level [`irontide_dht::DhtBroadcast`]. They observe
182    /// runtime DHT restarts (B11) so the torrent never holds a stale
183    /// `DhtHandle` clone whose backing actor has shut down.
184    ///
185    /// # Errors
186    /// Returns an error if disk registration fails or the torrent actor cannot be started.
187    #[allow(clippy::too_many_arguments)]
188    pub async fn from_torrent(
189        meta: TorrentMetaV1,
190        version: irontide_core::TorrentVersion,
191        meta_v2: Option<irontide_core::TorrentMetaV2>,
192        disk: DiskHandle,
193        disk_manager: DiskManagerHandle,
194        config: TorrentConfig,
195        dht_rx: irontide_dht::DhtReceiver,
196        dht_v6_rx: irontide_dht::DhtReceiver,
197        global_upload_bucket: Option<SharedBucket>,
198        global_download_bucket: Option<SharedBucket>,
199        slot_tuner: crate::slot_tuner::SlotTuner,
200        alert_tx: broadcast::Sender<Alert>,
201        alert_mask: Arc<AtomicU32>,
202        utp_socket: Option<irontide_utp::UtpSocket>,
203        utp_socket_v6: Option<irontide_utp::UtpSocket>,
204        ban_manager: irontide_session_types::SharedBanManager,
205        ip_filter: irontide_session_types::SharedIpFilter,
206        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
207        sam_session: Option<Arc<crate::i2p::SamSession>>,
208        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
209        factory: Arc<crate::transport::NetworkFactory>,
210        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
211        counters: Arc<crate::stats::SessionCounters>,
212    ) -> crate::Result<Self> {
213        let mut config = config;
214        // BEP 27: private torrents disable DHT, PEX, and LSD
215        if meta.info.private == Some(1) {
216            config.enable_dht = false;
217            config.enable_pex = false;
218            config.enable_lsd = false;
219        }
220
221        let info_hashes = match (&version, &meta_v2) {
222            (irontide_core::TorrentVersion::Hybrid, Some(v2_meta)) => {
223                if let Some(v2_hash) = v2_meta.info_hashes.v2 {
224                    irontide_core::InfoHashes::hybrid(meta.info_hash, v2_hash)
225                } else {
226                    irontide_core::InfoHashes::v1_only(meta.info_hash)
227                }
228            }
229            (irontide_core::TorrentVersion::V2Only, Some(v2_meta)) => v2_meta.info_hashes.clone(),
230            _ => irontide_core::InfoHashes::v1_only(meta.info_hash),
231        };
232
233        if meta.info.piece_length > config.max_piece_length {
234            return Err(crate::Error::InvalidSettings(format!(
235                "piece_length {} exceeds max_piece_length {}",
236                meta.info.piece_length, config.max_piece_length
237            )));
238        }
239
240        let num_pieces = meta.info.num_pieces() as u32;
241        let lengths = Lengths::new(
242            meta.info.total_length(),
243            meta.info.piece_length,
244            DEFAULT_CHUNK_SIZE,
245        );
246        let mut chunk_tracker = ChunkTracker::new(lengths.clone());
247
248        // Initialize HashPicker for v2/hybrid torrents and enable v2 block tracking
249        let hash_picker = if version.has_v2() {
250            if let Some(ref v2_meta) = meta_v2 {
251                chunk_tracker.enable_v2_tracking();
252
253                let block_size = 16384u64;
254                let blocks_per_piece = (meta.info.piece_length / block_size) as u32;
255
256                // Build FileHashInfo from v2 file tree
257                let v2_files = v2_meta.info.files();
258                let file_infos: Vec<irontide_core::FileHashInfo> = v2_files
259                    .iter()
260                    .filter_map(|f| {
261                        let root = f.attr.pieces_root?;
262                        let num_blocks = f.attr.length.div_ceil(block_size) as u32;
263                        let num_pieces = f.attr.length.div_ceil(meta.info.piece_length) as u32;
264                        Some(irontide_core::FileHashInfo {
265                            root,
266                            num_blocks,
267                            num_pieces,
268                        })
269                    })
270                    .collect();
271
272                if file_infos.is_empty() {
273                    None
274                } else {
275                    let mut picker = irontide_core::HashPicker::new(&file_infos, blocks_per_piece);
276
277                    // Pre-load piece-layer hashes from the .torrent file
278                    let _verified = picker.load_piece_layers(&v2_meta.piece_layers);
279
280                    Some(picker)
281                }
282            } else {
283                None
284            }
285        } else {
286            None
287        };
288
289        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
290        let file_priorities = vec![FilePriority::Normal; file_lengths.len()];
291        let wanted_pieces =
292            crate::piece_selector::build_wanted_pieces(&file_priorities, &file_lengths, &lengths);
293
294        let (cmd_tx, cmd_rx) = mpsc::channel(256);
295        let (event_tx, event_rx) = mpsc::channel(2048);
296        let (write_error_tx, write_error_rx) = mpsc::channel(64);
297        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
298        let (hash_result_tx, hash_result_rx) = mpsc::channel(64); // M96
299        let our_peer_id = if config.anonymous_mode {
300            PeerId::generate_anonymous().0
301        } else {
302            PeerId::generate().0
303        };
304
305        // Bind listener for incoming connections
306        // Try dual-stack [::]:port first, fall back to IPv4-only
307        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
308            .bind_tcp(SocketAddr::from((
309                std::net::Ipv6Addr::UNSPECIFIED,
310                config.listen_port,
311            )))
312            .await
313        {
314            Ok(l) => Some(l),
315            Err(_) => factory
316                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
317                .await
318                .ok(),
319        };
320        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
321
322        let mut tracker_manager = TrackerManager::from_torrent_filtered(
323            &meta,
324            our_peer_id,
325            config.listen_port,
326            config.url_security,
327            config.peer_dscp,
328            config.anonymous_mode,
329        );
330        tracker_manager.set_info_hashes(info_hashes.clone());
331
332        // BEP 7: include our I2P destination in tracker announces
333        if let Some(ref sam) = sam_session {
334            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
335        }
336
337        let enable_dht = config.enable_dht;
338
339        // M173 Lane B (B6): snapshot the broadcast at construction time
340        // to seed the initial peer-discovery channels. Future DHT
341        // restarts (B11) deliver peer batches via the watch
342        // subscription, not the initial snapshot.
343        let dht_initial = dht_rx.current();
344        let dht_v6_initial = dht_v6_rx.current();
345
346        // Start DHT peer discovery if enabled and available
347        let dht_peers_rx = if enable_dht {
348            if let Some(ref dht) = dht_initial {
349                match dht.get_peers(meta.info_hash).await {
350                    Ok(rx) => Some(rx),
351                    Err(e) => {
352                        warn!("failed to start DHT v4 get_peers: {e}");
353                        None
354                    }
355                }
356            } else {
357                None
358            }
359        } else {
360            None
361        };
362
363        let dht_v6_peers_rx = if enable_dht {
364            if let Some(ref dht6) = dht_v6_initial {
365                match dht6.get_peers(meta.info_hash).await {
366                    Ok(rx) => Some(rx),
367                    Err(e) => {
368                        debug!("failed to start DHT v6 get_peers: {e}");
369                        None
370                    }
371                }
372            } else {
373                None
374            }
375        } else {
376            None
377        };
378
379        // Dual-swarm: also search for v2 hash peers if hybrid
380        let v2_as_v1 = if info_hashes.is_hybrid() {
381            info_hashes
382                .v2
383                .map(|v2| Id20(v2.0[..20].try_into().unwrap()))
384        } else {
385            None
386        };
387        let (dht_v2_peers_rx, dht_v6_v2_peers_rx) =
388            if let (true, Some(v2_id)) = (enable_dht, v2_as_v1) {
389                let rx4 = if let Some(ref dht) = dht_initial {
390                    dht.get_peers(v2_id).await.ok()
391                } else {
392                    None
393                };
394                let rx6 = if let Some(ref dht6) = dht_v6_initial {
395                    dht6.get_peers(v2_id).await.ok()
396                } else {
397                    None
398                };
399                (rx4, rx6)
400            } else {
401                (None, None)
402            };
403
404        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
405        let download_bucket = Arc::new(parking_lot::Mutex::new(
406            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
407        ));
408        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
409            0,
410            0,
411            0,
412            0,
413            config.upload_rate_limit,
414            config.download_rate_limit,
415        );
416
417        let super_seed = if config.super_seeding {
418            Some(crate::super_seed::SuperSeedState::new())
419        } else {
420            None
421        };
422        // M118: broadcast channel for Have distribution — capacity scales with torrent size
423        let (have_broadcast_tx, _) =
424            tokio::sync::broadcast::channel(std::cmp::max(128, num_pieces as usize / 4));
425        let is_share_mode = config.share_mode;
426
427        let (piece_ready_tx, _) = broadcast::channel(64);
428        let initial_have = chunk_tracker.bitfield().clone();
429        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(initial_have);
430        let stream_read_semaphore =
431            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
432
433        let choker = Choker::with_algorithms(
434            initial_unchoke_slots(config.max_uploads_per_torrent),
435            config.seed_choking_algorithm,
436            config.choking_algorithm,
437            config.upload_rate_limit,
438            2,
439            20,
440        );
441
442        // M96: Wire hash pool into disk handle for V1-only torrents
443        let mut disk = disk;
444        if matches!(version, irontide_core::TorrentVersion::V1Only)
445            && let Some(pool) = &hash_pool
446        {
447            disk.set_hash_pool(pool.clone());
448            disk.set_hash_result_tx(hash_result_tx.clone());
449        }
450
451        // M116: Pre-compute file->piece mapping for zero-alloc completion checks.
452        let cached_files = Some(build_cached_file_info(&meta, &lengths));
453
454        // v0.173.4 (prong 1): seed the snapshot watch with an empty initial
455        let (order_map_tx, _order_map_rx_seed) =
456            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
457
458        let actor = TorrentActor {
459            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
460                config.lock_warn_threshold_ms,
461            ),
462            config,
463            info_hash: meta.info_hash,
464            our_peer_id,
465            state: TorrentState::Downloading,
466            disk: Some(disk),
467            disk_manager,
468            chunk_tracker: Some(chunk_tracker),
469            lengths: Some(lengths),
470            num_pieces,
471            streaming_pieces: BTreeSet::new(),
472            time_critical_pieces: BTreeSet::new(),
473            streaming_cursors: Vec::new(),
474            piece_ready_tx,
475            have_watch_tx,
476            have_watch_rx,
477            stream_read_semaphore,
478            file_priorities,
479            wanted_pieces,
480            end_game: EndGame::new(),
481            peers: HashMap::new(),
482            unchoke_durations: HashMap::new(),
483            cached_peer_rates: FxHashMap::default(),
484            refill_notify: Arc::new(tokio::sync::Notify::new()),
485            atomic_states: None,
486            block_maps: None,
487            steal_candidates: None,
488            last_steal_populate: Instant::now(),
489            piece_write_guards: None,
490            soft_reap_buf: Vec::new(),
491            eviction_history: std::collections::VecDeque::new(),
492            force_immediate_choker_tick: false,
493            piece_tracker: None,
494            order_map_dirty: false,
495            next_order_map_gen: 0,
496            order_map_tx,
497            piece_owner: Vec::new(),
498            peer_slab: crate::piece_reservation::PeerSlab::new(),
499            priority_pieces: BTreeSet::new(),
500            max_in_flight: 512,
501            reservation_notify: None,
502            last_tick_dispatch_state: None,
503            choker,
504            user_seed_mode: false,
505            user_forced: false,
506            max_connections: 0,
507            peer_states: None,
508            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
509            connect_permits: HashMap::new(),
510            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
511                std::collections::HashMap::new(),
512            )),
513            connect_rx: None,
514            metadata_downloader: None,
515            downloaded: 0,
516            uploaded: 0,
517            checking_progress: 0.0,
518            total_download: 0,
519            total_upload: 0,
520            total_failed_bytes: 0,
521            total_redundant_bytes: 0,
522            added_time: std::time::SystemTime::now()
523                .duration_since(std::time::UNIX_EPOCH)
524                .map_or(0, |d| d.as_secs() as i64),
525            completed_time: 0,
526            last_download: 0,
527            last_upload: 0,
528            last_seen_complete: 0,
529            active_duration: 0,
530            finished_duration: 0,
531            seeding_duration: 0,
532            active_since: Some(std::time::Instant::now()),
533            state_duration_since: None,
534            started_at: std::time::Instant::now(),
535            moving_storage: false,
536            has_incoming: false,
537            need_save_resume: false,
538            error: String::new(),
539            error_file: -1,
540            cmd_rx,
541            event_tx,
542            event_rx,
543            write_error_rx,
544            write_error_tx,
545            verify_result_rx,
546            verify_result_tx,
547            pending_verify: HashSet::new(),
548            piece_generations: vec![0u64; num_pieces as usize],
549            hash_result_rx,
550            hash_result_tx,
551            meta: Some(meta),
552            cached_files,
553            listener,
554            utp_socket,
555            utp_socket_v6,
556            tracker_manager,
557            tracker_result_rx: None,
558            dht_rx,
559            dht_v6_rx,
560            dht_enabled: enable_dht,
561            dht_peers_rx,
562            dht_v6_peers_rx,
563            dht_v6_empty_count: 0,
564            dht_v6_last_retry: None,
565            alert_tx,
566            alert_mask,
567            upload_bucket,
568            download_bucket,
569            global_upload_bucket,
570            global_download_bucket,
571            slot_tuner,
572            upload_bytes_interval: 0,
573            peak_download_rate: 0,
574            web_seeds: HashMap::new(),
575            banned_web_seeds: HashSet::new(),
576            web_seed_in_flight: HashMap::new(),
577            web_seed_stats: HashMap::new(),
578            pex_peer_count: 0,
579            lsd_peer_count: 0,
580            super_seed,
581            have_broadcast_tx,
582            suggested_to_peers: HashMap::new(),
583            predictive_have_sent: HashSet::new(),
584
585            ban_manager,
586            ip_filter,
587            piece_contributors: HashMap::new(),
588            parole_pieces: HashMap::new(),
589            external_ip: None,
590            share_lru: std::collections::VecDeque::new(),
591            share_max_pieces: if is_share_mode { 64 } else { 0 },
592            plugins,
593            hash_picker,
594            version,
595            meta_v2,
596            info_hashes,
597            dht_v2_peers_rx,
598            dht_v6_v2_peers_rx,
599            magnet_selected_files: None,
600            sam_session,
601            i2p_accept_rx: None,
602            i2p_peer_counter: 0,
603            i2p_destinations: HashMap::new(),
604            ssl_manager,
605            rate_limiter_set,
606            auto_sequential_active: false,
607            factory,
608            hash_pool_ref: hash_pool,
609            connect_attempts: 0,
610            connect_failures: 0,
611            choke_rotations: 0,
612            inflight_started: Vec::new(),
613            completed_piece_times: std::collections::VecDeque::new(),
614            piece_steals: 0,
615            holepunch_relayed: 0,
616            holepunch_relay_rate: HashMap::new(),
617            holepunch_cooldowns: HashMap::new(),
618            holepunch_pending: Vec::new(),
619            counters,
620        };
621
622        let spawn_info_hash = actor.info_hash;
623        let join_handle = tokio::spawn(actor.run());
624        // Monitor the actor task so panics/exits are logged instead of silently swallowed.
625        tokio::spawn(async move {
626            match join_handle.await {
627                Ok(()) => {
628                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
629                }
630                Err(e) if e.is_panic() => {
631                    let panic_payload = e.into_panic();
632                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
633                        (*s).to_string()
634                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
635                        s.clone()
636                    } else {
637                        "unknown panic payload".to_string()
638                    };
639                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
640                }
641                Err(e) => {
642                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
643                }
644            }
645        });
646        Ok(Self { cmd_tx })
647    }
648
649    /// Create a torrent session from a magnet link (metadata fetched via BEP 9).
650    ///
651    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
652    /// from the session-level [`irontide_dht::DhtBroadcast`]; see
653    /// [`Self::from_torrent`] for the rationale.
654    ///
655    /// # Errors
656    /// Returns an error if disk registration fails or the torrent actor cannot be started.
657    #[allow(clippy::too_many_arguments)]
658    pub async fn from_magnet(
659        magnet: Magnet,
660        disk_manager: DiskManagerHandle,
661        config: TorrentConfig,
662        dht_rx: irontide_dht::DhtReceiver,
663        dht_v6_rx: irontide_dht::DhtReceiver,
664        global_upload_bucket: Option<SharedBucket>,
665        global_download_bucket: Option<SharedBucket>,
666        slot_tuner: crate::slot_tuner::SlotTuner,
667        alert_tx: broadcast::Sender<Alert>,
668        alert_mask: Arc<AtomicU32>,
669        utp_socket: Option<irontide_utp::UtpSocket>,
670        utp_socket_v6: Option<irontide_utp::UtpSocket>,
671        ban_manager: irontide_session_types::SharedBanManager,
672        ip_filter: irontide_session_types::SharedIpFilter,
673        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
674        sam_session: Option<Arc<crate::i2p::SamSession>>,
675        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
676        factory: Arc<crate::transport::NetworkFactory>,
677        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
678        counters: Arc<crate::stats::SessionCounters>,
679    ) -> crate::Result<Self> {
680        let (cmd_tx, cmd_rx) = mpsc::channel(256);
681        let (event_tx, event_rx) = mpsc::channel(2048);
682        let (write_error_tx, write_error_rx) = mpsc::channel(64);
683        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
684        // M96: Dummy channel — replaced when metadata arrives and num_pieces is known
685        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
686        let our_peer_id = if config.anonymous_mode {
687            PeerId::generate_anonymous().0
688        } else {
689            PeerId::generate().0
690        };
691
692        // Try dual-stack [::]:port first, fall back to IPv4-only
693        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
694            .bind_tcp(SocketAddr::from((
695                std::net::Ipv6Addr::UNSPECIFIED,
696                config.listen_port,
697            )))
698            .await
699        {
700            Ok(l) => Some(l),
701            Err(_) => factory
702                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
703                .await
704                .ok(),
705        };
706        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
707
708        let mut tracker_manager = TrackerManager::empty(
709            magnet.info_hash(),
710            our_peer_id,
711            config.listen_port,
712            config.peer_dscp,
713            config.anonymous_mode,
714        );
715        // Add tracker URLs from the magnet link (BEP 9 §3.1)
716        for url in &magnet.trackers {
717            tracker_manager.add_tracker_url(url);
718        }
719
720        // BEP 7: include our I2P destination in tracker announces
721        if let Some(ref sam) = sam_session {
722            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
723        }
724
725        let enable_dht = config.enable_dht;
726
727        // M173 Lane B (B6): snapshot the broadcast at construction time
728        // for the initial peer-discovery wiring; future DHT restarts
729        // (B11) deliver new handles via the watch subscription.
730        let dht_initial = dht_rx.current();
731        let dht_v6_initial = dht_v6_rx.current();
732
733        // Start DHT peer discovery if enabled and available
734        let dht_peers_rx = if enable_dht {
735            if let Some(ref dht) = dht_initial {
736                match dht.get_peers(magnet.info_hash()).await {
737                    Ok(rx) => Some(rx),
738                    Err(e) => {
739                        warn!("failed to start DHT v4 get_peers: {e}");
740                        None
741                    }
742                }
743            } else {
744                None
745            }
746        } else {
747            None
748        };
749
750        let dht_v6_peers_rx = if enable_dht {
751            if let Some(ref dht6) = dht_v6_initial {
752                match dht6.get_peers(magnet.info_hash()).await {
753                    Ok(rx) => Some(rx),
754                    Err(e) => {
755                        debug!("failed to start DHT v6 get_peers: {e}");
756                        None
757                    }
758                }
759            } else {
760                None
761            }
762        } else {
763            None
764        };
765
766        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
767        let download_bucket = Arc::new(parking_lot::Mutex::new(
768            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
769        ));
770        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
771            0,
772            0,
773            0,
774            0,
775            config.upload_rate_limit,
776            config.download_rate_limit,
777        );
778
779        let super_seed = if config.super_seeding {
780            Some(crate::super_seed::SuperSeedState::new())
781        } else {
782            None
783        };
784        // M118: broadcast channel — start with min capacity for magnet (resized on metadata)
785        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
786        let is_share_mode = config.share_mode;
787        let magnet_selected_files = magnet.selected_files.clone();
788        let info_hashes = magnet.info_hashes.clone();
789
790        let (piece_ready_tx, _) = broadcast::channel(64);
791        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(0));
792        let stream_read_semaphore =
793            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
794
795        let choker = Choker::with_algorithms(
796            initial_unchoke_slots(config.max_uploads_per_torrent),
797            config.seed_choking_algorithm,
798            config.choking_algorithm,
799            config.upload_rate_limit,
800            2,
801            20,
802        );
803
804        let (order_map_tx, _order_map_rx_seed) =
805            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
806
807        let actor = TorrentActor {
808            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
809                config.lock_warn_threshold_ms,
810            ),
811            config,
812            info_hash: magnet.info_hash(),
813            our_peer_id,
814            state: TorrentState::FetchingMetadata,
815            disk: None,
816            disk_manager,
817            chunk_tracker: None,
818            lengths: None,
819            num_pieces: 0,
820            streaming_pieces: BTreeSet::new(),
821            time_critical_pieces: BTreeSet::new(),
822            streaming_cursors: Vec::new(),
823            piece_ready_tx,
824            have_watch_tx,
825            have_watch_rx,
826            stream_read_semaphore,
827            file_priorities: Vec::new(),
828            wanted_pieces: Bitfield::new(0),
829            end_game: EndGame::new(),
830            peers: HashMap::new(),
831            unchoke_durations: HashMap::new(),
832            cached_peer_rates: FxHashMap::default(),
833            refill_notify: Arc::new(tokio::sync::Notify::new()),
834            atomic_states: None,
835            block_maps: None,
836            steal_candidates: None,
837            last_steal_populate: Instant::now(),
838            piece_write_guards: None,
839            soft_reap_buf: Vec::new(),
840            eviction_history: std::collections::VecDeque::new(),
841            force_immediate_choker_tick: false,
842            piece_tracker: None,
843            order_map_dirty: false,
844            next_order_map_gen: 0,
845            order_map_tx,
846            piece_owner: Vec::new(),
847            peer_slab: crate::piece_reservation::PeerSlab::new(),
848            priority_pieces: BTreeSet::new(),
849            max_in_flight: 512,
850            reservation_notify: None,
851            last_tick_dispatch_state: None,
852            choker,
853            user_seed_mode: false,
854            user_forced: false,
855            max_connections: 0,
856            peer_states: None,
857            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
858            connect_permits: HashMap::new(),
859            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
860                std::collections::HashMap::new(),
861            )),
862            connect_rx: None,
863            metadata_downloader: Some(MetadataDownloader::new(magnet.info_hash())),
864            downloaded: 0,
865            uploaded: 0,
866            checking_progress: 0.0,
867            total_download: 0,
868            total_upload: 0,
869            total_failed_bytes: 0,
870            total_redundant_bytes: 0,
871            added_time: std::time::SystemTime::now()
872                .duration_since(std::time::UNIX_EPOCH)
873                .map_or(0, |d| d.as_secs() as i64),
874            completed_time: 0,
875            last_download: 0,
876            last_upload: 0,
877            last_seen_complete: 0,
878            active_duration: 0,
879            finished_duration: 0,
880            seeding_duration: 0,
881            active_since: Some(std::time::Instant::now()),
882            state_duration_since: None,
883            started_at: std::time::Instant::now(),
884            moving_storage: false,
885            has_incoming: false,
886            need_save_resume: false,
887            error: String::new(),
888            error_file: -1,
889            cmd_rx,
890            event_tx,
891            event_rx,
892            write_error_rx,
893            write_error_tx,
894            verify_result_rx,
895            verify_result_tx,
896            pending_verify: HashSet::new(),
897            piece_generations: Vec::new(),
898            hash_result_rx,
899            hash_result_tx,
900            meta: None,
901            cached_files: None,
902            listener,
903            utp_socket,
904            utp_socket_v6,
905            tracker_manager,
906            tracker_result_rx: None,
907            dht_rx,
908            dht_v6_rx,
909            dht_enabled: enable_dht,
910            dht_peers_rx,
911            dht_v6_peers_rx,
912            dht_v6_empty_count: 0,
913            dht_v6_last_retry: None,
914            alert_tx,
915            alert_mask,
916            upload_bucket,
917            download_bucket,
918            global_upload_bucket,
919            global_download_bucket,
920            slot_tuner,
921            upload_bytes_interval: 0,
922            peak_download_rate: 0,
923            web_seeds: HashMap::new(),
924            banned_web_seeds: HashSet::new(),
925            web_seed_in_flight: HashMap::new(),
926            web_seed_stats: HashMap::new(),
927            pex_peer_count: 0,
928            lsd_peer_count: 0,
929            super_seed,
930            have_broadcast_tx,
931            suggested_to_peers: HashMap::new(),
932            predictive_have_sent: HashSet::new(),
933
934            ban_manager,
935            ip_filter,
936            piece_contributors: HashMap::new(),
937            parole_pieces: HashMap::new(),
938            external_ip: None,
939            share_lru: std::collections::VecDeque::new(),
940            share_max_pieces: if is_share_mode { 64 } else { 0 },
941            plugins,
942            hash_picker: None,
943            version: irontide_core::TorrentVersion::V1Only,
944            meta_v2: None,
945            info_hashes,
946            dht_v2_peers_rx: None,
947            dht_v6_v2_peers_rx: None,
948            magnet_selected_files,
949            sam_session,
950            i2p_accept_rx: None,
951            i2p_peer_counter: 0,
952            i2p_destinations: HashMap::new(),
953            ssl_manager,
954            rate_limiter_set,
955            auto_sequential_active: false,
956            factory,
957            hash_pool_ref: hash_pool,
958            connect_attempts: 0,
959            connect_failures: 0,
960            choke_rotations: 0,
961            inflight_started: Vec::new(),
962            completed_piece_times: std::collections::VecDeque::new(),
963            piece_steals: 0,
964            holepunch_relayed: 0,
965            holepunch_relay_rate: HashMap::new(),
966            holepunch_cooldowns: HashMap::new(),
967            holepunch_pending: Vec::new(),
968            counters,
969        };
970
971        let spawn_info_hash = actor.info_hash;
972        let join_handle = tokio::spawn(actor.run());
973        tokio::spawn(async move {
974            match join_handle.await {
975                Ok(()) => {
976                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
977                }
978                Err(e) if e.is_panic() => {
979                    let panic_payload = e.into_panic();
980                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
981                        (*s).to_string()
982                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
983                        s.clone()
984                    } else {
985                        "unknown panic payload".to_string()
986                    };
987                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
988                }
989                Err(e) => {
990                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
991                }
992            }
993        });
994        Ok(Self { cmd_tx })
995    }
996
997    /// Send an incoming peer (routed by the session) to this torrent.
998    ///
999    /// # Errors
1000    /// Returns an error if the torrent actor has shut down.
1001    pub async fn send_incoming_peer(
1002        &self,
1003        stream: crate::transport::BoxedStream,
1004        addr: SocketAddr,
1005    ) -> crate::Result<()> {
1006        self.cmd_tx
1007            .send(TorrentCommand::IncomingPeer { stream, addr })
1008            .await
1009            .map_err(|_| crate::Error::Shutdown)
1010    }
1011
1012    /// Query current torrent statistics.
1013    ///
1014    /// # Errors
1015    ///
1016    /// Returns an error if the session is shut down.
1017    pub async fn stats(&self) -> crate::Result<TorrentStats> {
1018        let (tx, rx) = oneshot::channel();
1019        self.cmd_tx
1020            .send(TorrentCommand::Stats { reply: tx })
1021            .await
1022            .map_err(|_| crate::Error::Shutdown)?;
1023        rx.await.map_err(|_| crate::Error::Shutdown)
1024    }
1025
1026    /// v0.173.1: fetch the torrent's current metadata from the `TorrentActor`.
1027    ///
1028    /// Returns `Ok(None)` if metadata has not yet been assembled (magnet
1029    /// pre-resolution), `Ok(Some(meta))` once the info dict is known. Returns
1030    /// `Err(Shutdown)` if the actor has already exited. Callers that need the
1031    /// meta to exist should fall back to `crate::Error::MetadataNotReady` on
1032    /// the `Ok(None)` branch.
1033    ///
1034    /// This replaces `SessionActor.TorrentEntry.meta` as the single source of
1035    /// truth: see `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`
1036    /// for the Class A archaeology.
1037    ///
1038    /// # Errors
1039    ///
1040    /// Returns an error if the session is shut down.
1041    pub async fn get_meta(&self) -> crate::Result<Option<TorrentMetaV1>> {
1042        let (tx, rx) = oneshot::channel();
1043        self.cmd_tx
1044            .send(TorrentCommand::GetMeta { reply: tx })
1045            .await
1046            .map_err(|_| crate::Error::Shutdown)?;
1047        rx.await.map_err(|_| crate::Error::Shutdown)
1048    }
1049
1050    /// Add peer addresses to the available-peer pool.
1051    ///
1052    /// # Errors
1053    ///
1054    /// Returns an error if the session is shut down.
1055    pub async fn add_peers(&self, peers: Vec<SocketAddr>, source: PeerSource) -> crate::Result<()> {
1056        self.cmd_tx
1057            .send(TorrentCommand::AddPeers { peers, source })
1058            .await
1059            .map_err(|_| crate::Error::Shutdown)
1060    }
1061
1062    /// Pause the torrent session (disconnect peers, announce Stopped).
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if the session is shut down.
1067    pub async fn pause(&self) -> crate::Result<()> {
1068        self.cmd_tx
1069            .send(TorrentCommand::Pause)
1070            .await
1071            .map_err(|_| crate::Error::Shutdown)
1072    }
1073
1074    /// Queue a torrent via auto-manage (system-managed pause).
1075    ///
1076    /// # Errors
1077    ///
1078    /// Returns an error if the session is shut down.
1079    pub async fn queue(&self) -> crate::Result<()> {
1080        self.cmd_tx
1081            .send(TorrentCommand::Queue)
1082            .await
1083            .map_err(|_| crate::Error::Shutdown)
1084    }
1085
1086    /// M170: update the qBt-compat category label on this torrent.
1087    ///
1088    /// Pass `None` to clear the label. The change is visible via the
1089    /// next `stats()` call and persists across `save_resume_data`.
1090    ///
1091    /// # Errors
1092    ///
1093    /// Returns an error if the session is shut down.
1094    pub async fn set_category(&self, category: Option<String>) -> crate::Result<()> {
1095        let (tx, rx) = oneshot::channel();
1096        self.cmd_tx
1097            .send(TorrentCommand::SetCategory {
1098                category,
1099                reply: tx,
1100            })
1101            .await
1102            .map_err(|_| crate::Error::Shutdown)?;
1103        rx.await.map_err(|_| crate::Error::Shutdown)
1104    }
1105
1106    /// M171: replace this torrent's tag set wholesale (qBt-compat).
1107    ///
1108    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
1109    /// layer — always a wholesale replacement at the engine layer. The
1110    /// change is visible via the next `stats()` call and persists
1111    /// across `save_resume_data`.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if the session is shut down.
1116    pub async fn set_tags(&self, tags: Vec<String>) -> crate::Result<()> {
1117        let (tx, rx) = oneshot::channel();
1118        self.cmd_tx
1119            .send(TorrentCommand::SetTags { tags, reply: tx })
1120            .await
1121            .map_err(|_| crate::Error::Shutdown)?;
1122        rx.await.map_err(|_| crate::Error::Shutdown)
1123    }
1124
1125    /// Resume a paused torrent session (reconnect, announce Started).
1126    ///
1127    /// # Errors
1128    ///
1129    /// Returns an error if the session is shut down.
1130    pub async fn resume(&self) -> crate::Result<()> {
1131        self.cmd_tx
1132            .send(TorrentCommand::Resume)
1133            .await
1134            .map_err(|_| crate::Error::Shutdown)
1135    }
1136
1137    /// Gracefully shut down the torrent session.
1138    ///
1139    /// # Errors
1140    ///
1141    /// Returns an error if the session is shut down.
1142    pub async fn shutdown(&self) -> crate::Result<()> {
1143        // Best-effort send with timeout — if the channel is full or closed,
1144        // the actor will exit when all senders are dropped anyway.
1145        let _ = tokio::time::timeout(
1146            std::time::Duration::from_secs(5),
1147            self.cmd_tx.send(TorrentCommand::Shutdown),
1148        )
1149        .await;
1150        Ok(())
1151    }
1152
1153    /// Snapshot current torrent state into libtorrent-compatible resume data.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error if the I/O operation fails.
1158    pub async fn save_resume_data(&self) -> crate::Result<irontide_core::FastResumeData> {
1159        let (tx, rx) = oneshot::channel();
1160        self.cmd_tx
1161            .send(TorrentCommand::SaveResumeData { reply: tx })
1162            .await
1163            .map_err(|_| crate::Error::Shutdown)?;
1164        rx.await.map_err(|_| crate::Error::Shutdown)?
1165    }
1166
1167    /// Clear the `need_save_resume` dirty flag after a successful file save.
1168    ///
1169    /// # Errors
1170    /// Returns an error if the torrent actor has shut down.
1171    pub async fn clear_save_resume_flag(&self) -> crate::Result<()> {
1172        self.cmd_tx
1173            .send(TorrentCommand::ClearSaveResumeFlag)
1174            .await
1175            .map_err(|_| crate::Error::Shutdown)
1176    }
1177
1178    /// M245 F1 — atomically take resume data IFF the torrent is dirty.
1179    ///
1180    /// Replaces the racy `stats()` → `save_resume_data()` → `clear_save_resume_flag()`
1181    /// three-step the session's periodic saver used to run per torrent. The
1182    /// actor reads `need_save_resume`, builds the resume data, and clears the
1183    /// flag in ONE indivisible command turn (no `.await` between read and
1184    /// clear), so a dirty mark set concurrently can never be lost to a clear
1185    /// that races the build.
1186    ///
1187    /// Returns `Ok(None)` when the torrent is clean (nothing to write),
1188    /// `Ok(Some(data))` when dirty data was taken and the flag cleared. On a
1189    /// build error the flag is left set so the torrent is retried next cycle.
1190    ///
1191    /// # Errors
1192    /// Returns an error if the torrent actor has shut down, or if building the
1193    /// resume data fails.
1194    pub async fn take_resume_if_dirty(
1195        &self,
1196    ) -> crate::Result<Option<irontide_core::FastResumeData>> {
1197        let (tx, rx) = oneshot::channel();
1198        self.cmd_tx
1199            .send(TorrentCommand::TakeResumeIfDirty { reply: tx })
1200            .await
1201            .map_err(|_| crate::Error::Shutdown)?;
1202        rx.await.map_err(|_| crate::Error::Shutdown)?
1203    }
1204
1205    /// M245 F1 — re-arm `need_save_resume` after a failed resume WRITE.
1206    ///
1207    /// The session saver calls this when the disk write fails AFTER
1208    /// [`Self::take_resume_if_dirty`] already cleared the flag, so the torrent
1209    /// is re-marked dirty and retried on the next save cycle instead of being
1210    /// silently dropped. Fire-and-forget.
1211    ///
1212    /// # Errors
1213    /// Returns an error if the torrent actor has shut down.
1214    pub async fn mark_resume_dirty(&self) -> crate::Result<()> {
1215        self.cmd_tx
1216            .send(TorrentCommand::MarkResumeDirty)
1217            .await
1218            .map_err(|_| crate::Error::Shutdown)
1219    }
1220
1221    /// Restore a piece bitmap from resume data (M161 Phase 4).
1222    ///
1223    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
1224    /// Returns an error if the bitfield length does not match the torrent's
1225    /// piece count or if the chunk tracker is not yet initialized.
1226    ///
1227    /// # Errors
1228    ///
1229    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1230    /// Returns [`crate::Error::InvalidSettings`] if the bitfield is invalid.
1231    pub async fn restore_resume_bitmap(&self, pieces: Vec<u8>) -> crate::Result<()> {
1232        let (tx, rx) = oneshot::channel();
1233        self.cmd_tx
1234            .send(TorrentCommand::RestoreResumeBitmap { pieces, reply: tx })
1235            .await
1236            .map_err(|_| crate::Error::Shutdown)?;
1237        rx.await.map_err(|_| crate::Error::Shutdown)?
1238    }
1239
1240    /// M178: Restore per-URL web-seed stats from resume data.
1241    ///
1242    /// # Errors
1243    ///
1244    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1245    pub async fn restore_web_seed_stats(
1246        &self,
1247        stats: HashMap<String, irontide_core::WebSeedStats>,
1248    ) -> crate::Result<()> {
1249        let (tx, rx) = oneshot::channel();
1250        self.cmd_tx
1251            .send(TorrentCommand::RestoreWebSeedStats { stats, reply: tx })
1252            .await
1253            .map_err(|_| crate::Error::Shutdown)?;
1254        rx.await.map_err(|_| crate::Error::Shutdown)?
1255    }
1256
1257    /// M178 Lane B3: cumulative `(pex_peer_count, lsd_peer_count)` for
1258    /// this torrent. Both counters track UNIQUE peers — duplicate
1259    /// announcements from the same peer count once.
1260    ///
1261    /// # Errors
1262    ///
1263    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1264    pub async fn peer_source_counts(&self) -> crate::Result<(usize, usize)> {
1265        let (tx, rx) = oneshot::channel();
1266        self.cmd_tx
1267            .send(TorrentCommand::GetPeerSourceCounts { reply: tx })
1268            .await
1269            .map_err(|_| crate::Error::Shutdown)?;
1270        rx.await.map_err(|_| crate::Error::Shutdown)
1271    }
1272
1273    /// Snapshot the per-peer cumulative unchoke duration for this torrent.
1274    ///
1275    /// # Errors
1276    ///
1277    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1278    pub async fn query_unchoke_durations(
1279        &self,
1280    ) -> crate::Result<HashMap<SocketAddr, std::time::Duration>> {
1281        let (tx, rx) = oneshot::channel();
1282        self.cmd_tx
1283            .send(TorrentCommand::QueryUnchokeDurations { reply: tx })
1284            .await
1285            .map_err(|_| crate::Error::Shutdown)?;
1286        rx.await.map_err(|_| crate::Error::Shutdown)
1287    }
1288
1289    /// M178 Lane C: snapshot per-URL `WebSeedStats` for this torrent.
1290    ///
1291    /// # Errors
1292    ///
1293    /// Returns [`crate::Error::Shutdown`] if the actor has stopped.
1294    pub async fn get_web_seed_stats(&self) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
1295        let (tx, rx) = oneshot::channel();
1296        self.cmd_tx
1297            .send(TorrentCommand::GetWebSeedStats { reply: tx })
1298            .await
1299            .map_err(|_| crate::Error::Shutdown)?;
1300        rx.await.map_err(|_| crate::Error::Shutdown)
1301    }
1302
1303    /// Set the download priority for a specific file.
1304    ///
1305    /// # Errors
1306    ///
1307    /// Returns an error if the session is shut down.
1308    pub async fn set_file_priority(
1309        &self,
1310        index: usize,
1311        priority: irontide_core::FilePriority,
1312    ) -> crate::Result<()> {
1313        let (tx, rx) = oneshot::channel();
1314        self.cmd_tx
1315            .send(TorrentCommand::SetFilePriority {
1316                index,
1317                priority,
1318                reply: tx,
1319            })
1320            .await
1321            .map_err(|_| crate::Error::Shutdown)?;
1322        rx.await.map_err(|_| crate::Error::Shutdown)?
1323    }
1324
1325    /// Get the current per-file priorities.
1326    ///
1327    /// # Errors
1328    ///
1329    /// Returns an error if the session is shut down.
1330    pub async fn file_priorities(&self) -> crate::Result<Vec<irontide_core::FilePriority>> {
1331        let (tx, rx) = oneshot::channel();
1332        self.cmd_tx
1333            .send(TorrentCommand::FilePriorities { reply: tx })
1334            .await
1335            .map_err(|_| crate::Error::Shutdown)?;
1336        rx.await.map_err(|_| crate::Error::Shutdown)
1337    }
1338
1339    /// Get the list of all configured trackers with their status.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns an error if the session is shut down.
1344    pub async fn tracker_list(&self) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
1345        let (tx, rx) = oneshot::channel();
1346        self.cmd_tx
1347            .send(TorrentCommand::TrackerList { reply: tx })
1348            .await
1349            .map_err(|_| crate::Error::Shutdown)?;
1350        rx.await.map_err(|_| crate::Error::Shutdown)
1351    }
1352
1353    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged).
1354    ///
1355    /// # Errors
1356    ///
1357    /// Returns an error if the session is shut down.
1358    pub async fn get_web_seeds(&self) -> crate::Result<Vec<String>> {
1359        let (tx, rx) = oneshot::channel();
1360        self.cmd_tx
1361            .send(TorrentCommand::GetWebSeeds { reply: tx })
1362            .await
1363            .map_err(|_| crate::Error::Shutdown)?;
1364        rx.await.map_err(|_| crate::Error::Shutdown)
1365    }
1366
1367    /// M171 Lane B: snapshot the per-piece qBt state codes.
1368    ///
1369    /// # Errors
1370    ///
1371    /// Returns an error if the session is shut down.
1372    pub async fn get_piece_states(&self) -> crate::Result<Vec<u8>> {
1373        let (tx, rx) = oneshot::channel();
1374        self.cmd_tx
1375            .send(TorrentCommand::GetPieceStates { reply: tx })
1376            .await
1377            .map_err(|_| crate::Error::Shutdown)?;
1378        rx.await.map_err(|_| crate::Error::Shutdown)
1379    }
1380
1381    /// M171 Lane B: paginated piece hash list.
1382    ///
1383    /// `offset` and `limit` are clamped to the real hash count inside
1384    /// the actor — callers can pass arbitrary values without overflow
1385    /// concerns.
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns an error if the session is shut down.
1390    pub async fn get_piece_hashes(&self, offset: u32, limit: u32) -> crate::Result<Vec<String>> {
1391        let (tx, rx) = oneshot::channel();
1392        self.cmd_tx
1393            .send(TorrentCommand::GetPieceHashes {
1394                offset,
1395                limit,
1396                reply: tx,
1397            })
1398            .await
1399            .map_err(|_| crate::Error::Shutdown)?;
1400        // M245 L3: the actor returns the raw windowed hash bytes; hex-encode
1401        // here, OFF the recv loop. Output stays byte-identical to the pre-M245
1402        // `Vec<String>` (40-char SHA-1 / 64-char SHA-256 hex).
1403        let raw = rx.await.map_err(|_| crate::Error::Shutdown)?;
1404        Ok(raw.iter().map(hex::encode).collect())
1405    }
1406
1407    /// Force all trackers to re-announce immediately.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the session is shut down.
1412    pub async fn force_reannounce(&self) -> crate::Result<()> {
1413        self.cmd_tx
1414            .send(TorrentCommand::ForceReannounce)
1415            .await
1416            .map_err(|_| crate::Error::Shutdown)
1417    }
1418
1419    /// Scrape trackers for seeder/leecher counts.
1420    ///
1421    /// # Errors
1422    ///
1423    /// Returns an error if the session is shut down.
1424    pub async fn scrape(&self) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
1425        let (tx, rx) = oneshot::channel();
1426        self.cmd_tx
1427            .send(TorrentCommand::Scrape { reply: tx })
1428            .await
1429            .map_err(|_| crate::Error::Shutdown)?;
1430        rx.await.map_err(|_| crate::Error::Shutdown)
1431    }
1432
1433    /// Open a streaming reader for a file within the torrent.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns an error if the session is shut down.
1438    pub async fn open_file(
1439        &self,
1440        file_index: usize,
1441    ) -> crate::Result<crate::streaming::FileStream> {
1442        let (tx, rx) = oneshot::channel();
1443        self.cmd_tx
1444            .send(TorrentCommand::OpenFile {
1445                file_index,
1446                reply: tx,
1447            })
1448            .await
1449            .map_err(|_| crate::Error::Shutdown)?;
1450        let handle = rx.await.map_err(|_| crate::Error::Shutdown)??;
1451        Ok(crate::streaming::FileStream::from_handle(handle))
1452    }
1453
1454    /// Update the external IP for BEP 40 peer priority sorting.
1455    ///
1456    /// # Errors
1457    /// Returns an error if the torrent actor has shut down.
1458    pub async fn update_external_ip(&self, ip: std::net::IpAddr) -> crate::Result<()> {
1459        self.cmd_tx
1460            .send(TorrentCommand::UpdateExternalIp { ip })
1461            .await
1462            .map_err(|_| crate::Error::Shutdown)
1463    }
1464
1465    /// Move torrent data files to a new download directory.
1466    ///
1467    /// Relocates existing files (rename or copy+delete), re-registers storage
1468    /// with the disk manager, and fires a `StorageMoved` alert on success.
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if the session is shut down.
1473    pub async fn move_storage(&self, new_path: std::path::PathBuf) -> crate::Result<()> {
1474        let (tx, rx) = oneshot::channel();
1475        self.cmd_tx
1476            .send(TorrentCommand::MoveStorage {
1477                new_path,
1478                reply: tx,
1479            })
1480            .await
1481            .map_err(|_| crate::Error::Shutdown)?;
1482        rx.await.map_err(|_| crate::Error::Shutdown)?
1483    }
1484
1485    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
1486    ///
1487    /// # Errors
1488    ///
1489    /// Returns an error if the data cannot be parsed or I/O fails.
1490    pub async fn set_download_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1491        let (tx, rx) = oneshot::channel();
1492        self.cmd_tx
1493            .send(TorrentCommand::SetDownloadLimit {
1494                bytes_per_sec,
1495                reply: tx,
1496            })
1497            .await
1498            .map_err(|_| crate::Error::Shutdown)?;
1499        rx.await.map_err(|_| crate::Error::Shutdown)
1500    }
1501
1502    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
1503    ///
1504    /// # Errors
1505    ///
1506    /// Returns an error if the data cannot be parsed or I/O fails.
1507    pub async fn set_upload_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1508        let (tx, rx) = oneshot::channel();
1509        self.cmd_tx
1510            .send(TorrentCommand::SetUploadLimit {
1511                bytes_per_sec,
1512                reply: tx,
1513            })
1514            .await
1515            .map_err(|_| crate::Error::Shutdown)?;
1516        rx.await.map_err(|_| crate::Error::Shutdown)
1517    }
1518
1519    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
1520    ///
1521    /// # Errors
1522    ///
1523    /// Returns an error if the data cannot be parsed or I/O fails.
1524    pub async fn download_limit(&self) -> crate::Result<u64> {
1525        let (tx, rx) = oneshot::channel();
1526        self.cmd_tx
1527            .send(TorrentCommand::DownloadLimit { reply: tx })
1528            .await
1529            .map_err(|_| crate::Error::Shutdown)?;
1530        rx.await.map_err(|_| crate::Error::Shutdown)
1531    }
1532
1533    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
1534    ///
1535    /// # Errors
1536    ///
1537    /// Returns an error if the data cannot be parsed or I/O fails.
1538    pub async fn upload_limit(&self) -> crate::Result<u64> {
1539        let (tx, rx) = oneshot::channel();
1540        self.cmd_tx
1541            .send(TorrentCommand::UploadLimit { reply: tx })
1542            .await
1543            .map_err(|_| crate::Error::Shutdown)?;
1544        rx.await.map_err(|_| crate::Error::Shutdown)
1545    }
1546
1547    /// Enable or disable sequential (in-order) piece downloading.
1548    ///
1549    /// # Errors
1550    ///
1551    /// Returns an error if the data cannot be parsed or I/O fails.
1552    pub async fn set_sequential_download(&self, enabled: bool) -> crate::Result<()> {
1553        let (tx, rx) = oneshot::channel();
1554        self.cmd_tx
1555            .send(TorrentCommand::SetSequentialDownload { enabled, reply: tx })
1556            .await
1557            .map_err(|_| crate::Error::Shutdown)?;
1558        rx.await.map_err(|_| crate::Error::Shutdown)
1559    }
1560
1561    /// Query whether sequential downloading is enabled.
1562    ///
1563    /// # Errors
1564    ///
1565    /// Returns an error if the data cannot be parsed or I/O fails.
1566    pub async fn is_sequential_download(&self) -> crate::Result<bool> {
1567        let (tx, rx) = oneshot::channel();
1568        self.cmd_tx
1569            .send(TorrentCommand::IsSequentialDownload { reply: tx })
1570            .await
1571            .map_err(|_| crate::Error::Shutdown)?;
1572        rx.await.map_err(|_| crate::Error::Shutdown)
1573    }
1574
1575    /// Enable or disable BEP 16 super seeding mode.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if the session is shut down.
1580    pub async fn set_super_seeding(&self, enabled: bool) -> crate::Result<()> {
1581        let (tx, rx) = oneshot::channel();
1582        self.cmd_tx
1583            .send(TorrentCommand::SetSuperSeeding { enabled, reply: tx })
1584            .await
1585            .map_err(|_| crate::Error::Shutdown)?;
1586        rx.await.map_err(|_| crate::Error::Shutdown)
1587    }
1588
1589    /// Query whether BEP 16 super seeding mode is enabled.
1590    ///
1591    /// # Errors
1592    ///
1593    /// Returns an error if the session is shut down.
1594    pub async fn is_super_seeding(&self) -> crate::Result<bool> {
1595        let (tx, rx) = oneshot::channel();
1596        self.cmd_tx
1597            .send(TorrentCommand::IsSuperSeeding { reply: tx })
1598            .await
1599            .map_err(|_| crate::Error::Shutdown)?;
1600        rx.await.map_err(|_| crate::Error::Shutdown)
1601    }
1602
1603    /// Enable or disable user-requested seed-only mode (M159).
1604    ///
1605    /// When `enabled` is `true`, the actor stops scheduling new block requests
1606    /// and cancels all in-flight requests, but keeps existing peers connected
1607    /// and continues serving uploads. Toggling back to `false` restores normal
1608    /// piece scheduling.
1609    ///
1610    /// # Errors
1611    ///
1612    /// Returns [`crate::Error::Shutdown`] if the torrent actor has terminated.
1613    pub async fn set_seed_mode(&self, enabled: bool) -> crate::Result<()> {
1614        let (tx, rx) = oneshot::channel();
1615        self.cmd_tx
1616            .send(TorrentCommand::SetSeedMode { enabled, reply: tx })
1617            .await
1618            .map_err(|_| crate::Error::Shutdown)?;
1619        rx.await.map_err(|_| crate::Error::Shutdown)
1620    }
1621
1622    /// Add a new tracker URL to this torrent (fire-and-forget).
1623    ///
1624    /// The URL is validated and deduplicated by the tracker manager.
1625    ///
1626    /// # Errors
1627    ///
1628    /// Returns an error if the session is shut down.
1629    pub async fn add_tracker(&self, url: String) -> crate::Result<()> {
1630        self.cmd_tx
1631            .send(TorrentCommand::AddTracker { url })
1632            .await
1633            .map_err(|_| crate::Error::Shutdown)
1634    }
1635
1636    /// Replace all tracker URLs for this torrent.
1637    ///
1638    /// # Errors
1639    ///
1640    /// Returns an error if the session is shut down.
1641    pub async fn replace_trackers(&self, urls: Vec<String>) -> crate::Result<()> {
1642        let (tx, rx) = oneshot::channel();
1643        self.cmd_tx
1644            .send(TorrentCommand::ReplaceTrackers { urls, reply: tx })
1645            .await
1646            .map_err(|_| crate::Error::Shutdown)?;
1647        rx.await.map_err(|_| crate::Error::Shutdown)
1648    }
1649
1650    /// Trigger a full piece verification (force recheck).
1651    ///
1652    /// Transitions the torrent through `Checking` state, clears all piece
1653    /// completion data, re-verifies every piece against its hash, then
1654    /// transitions to `Seeding` (all valid) or `Downloading` (some missing).
1655    /// Returns after the check is complete.
1656    ///
1657    /// # Errors
1658    ///
1659    /// Returns an error if the session is shut down.
1660    pub async fn force_recheck(&self) -> crate::Result<()> {
1661        let (tx, rx) = oneshot::channel();
1662        self.cmd_tx
1663            .send(TorrentCommand::ForceRecheck { reply: tx })
1664            .await
1665            .map_err(|_| crate::Error::Shutdown)?;
1666        rx.await.map_err(|_| crate::Error::Shutdown)?
1667    }
1668
1669    /// Rename a file within the torrent on disk.
1670    ///
1671    /// Changes the filename of the specified file (by index) to `new_name`.
1672    /// The file stays in the same directory; only the filename component changes.
1673    /// Fires a `FileRenamed` alert on success.
1674    ///
1675    /// # Errors
1676    ///
1677    /// Returns an error if the session is shut down.
1678    pub async fn rename_file(&self, file_index: usize, new_name: String) -> crate::Result<()> {
1679        let (tx, rx) = oneshot::channel();
1680        self.cmd_tx
1681            .send(TorrentCommand::RenameFile {
1682                file_index,
1683                new_name,
1684                reply: tx,
1685            })
1686            .await
1687            .map_err(|_| crate::Error::Shutdown)?;
1688        rx.await.map_err(|_| crate::Error::Shutdown)?
1689    }
1690
1691    /// Route an incoming SSL peer (TLS already completed) to this torrent (M42).
1692    ///
1693    /// # Errors
1694    /// Returns an error if the torrent actor has shut down.
1695    pub async fn spawn_ssl_peer(
1696        &self,
1697        addr: SocketAddr,
1698        stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1699    ) -> crate::Result<()> {
1700        self.cmd_tx
1701            .send(TorrentCommand::SpawnSslPeer {
1702                addr,
1703                stream: crate::types::BoxedAsyncStream(Box::new(stream)),
1704            })
1705            .await
1706            .map_err(|_| crate::Error::Shutdown)
1707    }
1708
1709    /// Set the per-torrent maximum number of connections (0 = use global default).
1710    ///
1711    /// # Errors
1712    ///
1713    /// Returns an error if the connection or binding fails.
1714    pub async fn set_max_connections(&self, limit: usize) -> crate::Result<()> {
1715        let (tx, rx) = oneshot::channel();
1716        self.cmd_tx
1717            .send(TorrentCommand::SetMaxConnections { limit, reply: tx })
1718            .await
1719            .map_err(|_| crate::Error::Shutdown)?;
1720        rx.await.map_err(|_| crate::Error::Shutdown)
1721    }
1722
1723    /// Get the current per-torrent maximum connection limit (0 = use global default).
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns an error if the connection or binding fails.
1728    pub async fn max_connections(&self) -> crate::Result<usize> {
1729        let (tx, rx) = oneshot::channel();
1730        self.cmd_tx
1731            .send(TorrentCommand::MaxConnections { reply: tx })
1732            .await
1733            .map_err(|_| crate::Error::Shutdown)?;
1734        rx.await.map_err(|_| crate::Error::Shutdown)
1735    }
1736
1737    /// Set the per-torrent maximum number of upload slots (unchoke slots).
1738    ///
1739    /// # Errors
1740    ///
1741    /// Returns an error if the data cannot be parsed or I/O fails.
1742    pub async fn set_max_uploads(&self, limit: usize) -> crate::Result<()> {
1743        let (tx, rx) = oneshot::channel();
1744        self.cmd_tx
1745            .send(TorrentCommand::SetMaxUploads { limit, reply: tx })
1746            .await
1747            .map_err(|_| crate::Error::Shutdown)?;
1748        rx.await.map_err(|_| crate::Error::Shutdown)
1749    }
1750
1751    /// Get the current per-torrent maximum upload slots (unchoke slots).
1752    ///
1753    /// # Errors
1754    ///
1755    /// Returns an error if the data cannot be parsed or I/O fails.
1756    pub async fn max_uploads(&self) -> crate::Result<usize> {
1757        let (tx, rx) = oneshot::channel();
1758        self.cmd_tx
1759            .send(TorrentCommand::MaxUploads { reply: tx })
1760            .await
1761            .map_err(|_| crate::Error::Shutdown)?;
1762        rx.await.map_err(|_| crate::Error::Shutdown)
1763    }
1764
1765    /// Get per-peer details for all connected peers.
1766    ///
1767    /// # Errors
1768    ///
1769    /// Returns an error if the session is shut down.
1770    pub async fn get_peer_info(&self) -> crate::Result<Vec<PeerInfo>> {
1771        let (tx, rx) = oneshot::channel();
1772        self.cmd_tx
1773            .send(TorrentCommand::GetPeerInfo { reply: tx })
1774            .await
1775            .map_err(|_| crate::Error::Shutdown)?;
1776        rx.await.map_err(|_| crate::Error::Shutdown)
1777    }
1778
1779    /// Get in-flight piece download status (the download queue).
1780    ///
1781    /// # Errors
1782    ///
1783    /// Returns an error if the data cannot be parsed or I/O fails.
1784    pub async fn get_download_queue(&self) -> crate::Result<Vec<PartialPieceInfo>> {
1785        let (tx, rx) = oneshot::channel();
1786        self.cmd_tx
1787            .send(TorrentCommand::GetDownloadQueue { reply: tx })
1788            .await
1789            .map_err(|_| crate::Error::Shutdown)?;
1790        rx.await.map_err(|_| crate::Error::Shutdown)
1791    }
1792
1793    /// Check whether a specific piece has been downloaded.
1794    ///
1795    /// # Errors
1796    ///
1797    /// Returns an error if the session is shut down.
1798    pub async fn have_piece(&self, index: u32) -> crate::Result<bool> {
1799        let (tx, rx) = oneshot::channel();
1800        self.cmd_tx
1801            .send(TorrentCommand::HavePiece { index, reply: tx })
1802            .await
1803            .map_err(|_| crate::Error::Shutdown)?;
1804        rx.await.map_err(|_| crate::Error::Shutdown)
1805    }
1806
1807    /// Get per-piece availability counts from connected peers.
1808    ///
1809    /// # Errors
1810    ///
1811    /// Returns an error if the session is shut down.
1812    pub async fn piece_availability(&self) -> crate::Result<Vec<u32>> {
1813        let (tx, rx) = oneshot::channel();
1814        self.cmd_tx
1815            .send(TorrentCommand::PieceAvailability { reply: tx })
1816            .await
1817            .map_err(|_| crate::Error::Shutdown)?;
1818        rx.await.map_err(|_| crate::Error::Shutdown)
1819    }
1820
1821    /// Get per-file bytes-downloaded progress.
1822    ///
1823    /// # Errors
1824    ///
1825    /// Returns an error if the session is shut down.
1826    pub async fn file_progress(&self) -> crate::Result<Vec<u64>> {
1827        let (tx, rx) = oneshot::channel();
1828        self.cmd_tx
1829            .send(TorrentCommand::FileProgress { reply: tx })
1830            .await
1831            .map_err(|_| crate::Error::Shutdown)?;
1832        rx.await.map_err(|_| crate::Error::Shutdown)
1833    }
1834
1835    /// Get the torrent's identity hashes (v1 and/or v2).
1836    ///
1837    /// # Errors
1838    ///
1839    /// Returns an error if the session is shut down.
1840    pub async fn info_hashes(&self) -> crate::Result<irontide_core::InfoHashes> {
1841        let (tx, rx) = oneshot::channel();
1842        self.cmd_tx
1843            .send(TorrentCommand::InfoHashes { reply: tx })
1844            .await
1845            .map_err(|_| crate::Error::Shutdown)?;
1846        rx.await.map_err(|_| crate::Error::Shutdown)
1847    }
1848
1849    /// Get the full v1 metainfo, if available.
1850    ///
1851    /// Returns `None` for magnet links before metadata has been received.
1852    ///
1853    /// # Errors
1854    ///
1855    /// Returns an error if the session is shut down.
1856    pub async fn torrent_file(&self) -> crate::Result<Option<TorrentMetaV1>> {
1857        let (tx, rx) = oneshot::channel();
1858        self.cmd_tx
1859            .send(TorrentCommand::TorrentFile { reply: tx })
1860            .await
1861            .map_err(|_| crate::Error::Shutdown)?;
1862        rx.await.map_err(|_| crate::Error::Shutdown)
1863    }
1864
1865    /// Get the full v2 metainfo, if available.
1866    ///
1867    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
1868    /// links before metadata has been received.
1869    ///
1870    /// # Errors
1871    ///
1872    /// Returns an error if the session is shut down.
1873    pub async fn torrent_file_v2(&self) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
1874        let (tx, rx) = oneshot::channel();
1875        self.cmd_tx
1876            .send(TorrentCommand::TorrentFileV2 { reply: tx })
1877            .await
1878            .map_err(|_| crate::Error::Shutdown)?;
1879        rx.await.map_err(|_| crate::Error::Shutdown)
1880    }
1881
1882    /// Force an immediate DHT announce for this torrent.
1883    ///
1884    /// Fire-and-forget at the torrent level — the DHT announce is best-effort.
1885    ///
1886    /// # Errors
1887    ///
1888    /// Returns an error if the session is shut down.
1889    pub async fn force_dht_announce(&self) -> crate::Result<()> {
1890        self.cmd_tx
1891            .send(TorrentCommand::ForceDhtAnnounce)
1892            .await
1893            .map_err(|_| crate::Error::Shutdown)
1894    }
1895
1896    /// Read all data for a specific piece from disk.
1897    ///
1898    /// Returns the complete piece data as `Bytes`. The piece must have been
1899    /// downloaded already; use [`have_piece`](Self::have_piece) to check first.
1900    ///
1901    /// # Errors
1902    ///
1903    /// Returns an error if the data cannot be parsed or I/O fails.
1904    pub async fn read_piece(&self, index: u32) -> crate::Result<Bytes> {
1905        let (tx, rx) = oneshot::channel();
1906        self.cmd_tx
1907            .send(TorrentCommand::ReadPiece { index, reply: tx })
1908            .await
1909            .map_err(|_| crate::Error::Shutdown)?;
1910        rx.await.map_err(|_| crate::Error::Shutdown)?
1911    }
1912
1913    /// Flush the disk write cache, ensuring all buffered writes are persisted.
1914    ///
1915    /// # Errors
1916    ///
1917    /// Returns an error if the session is shut down.
1918    pub async fn flush_cache(&self) -> crate::Result<()> {
1919        let (tx, rx) = oneshot::channel();
1920        self.cmd_tx
1921            .send(TorrentCommand::FlushCache { reply: tx })
1922            .await
1923            .map_err(|_| crate::Error::Shutdown)?;
1924        rx.await.map_err(|_| crate::Error::Shutdown)?
1925    }
1926
1927    /// Check whether this handle refers to a live torrent.
1928    ///
1929    /// Returns `false` after the torrent has been removed or shut down.
1930    /// This is a synchronous check on the channel state — no command dispatch.
1931    #[must_use]
1932    pub fn is_valid(&self) -> bool {
1933        !self.cmd_tx.is_closed()
1934    }
1935
1936    /// Clear any error state on the torrent and resume if it was paused due to error.
1937    ///
1938    /// # Errors
1939    ///
1940    /// Returns an error if the session is shut down.
1941    pub async fn clear_error(&self) -> crate::Result<()> {
1942        self.cmd_tx
1943            .send(TorrentCommand::ClearError)
1944            .await
1945            .map_err(|_| crate::Error::Shutdown)
1946    }
1947
1948    /// Get per-file open/mode status based on the current torrent state.
1949    ///
1950    /// Returns one [`crate::types::FileStatus`] entry per file in the torrent.
1951    ///
1952    /// # Errors
1953    ///
1954    /// Returns an error if the session is shut down.
1955    pub async fn file_status(&self) -> crate::Result<Vec<crate::types::FileStatus>> {
1956        let (tx, rx) = oneshot::channel();
1957        self.cmd_tx
1958            .send(TorrentCommand::FileStatus { reply: tx })
1959            .await
1960            .map_err(|_| crate::Error::Shutdown)?;
1961        rx.await.map_err(|_| crate::Error::Shutdown)
1962    }
1963
1964    /// Read the current torrent state as a [`TorrentFlags`] bitflag set.
1965    ///
1966    /// # Errors
1967    ///
1968    /// Returns an error if the session is shut down.
1969    pub async fn flags(&self) -> crate::Result<crate::types::TorrentFlags> {
1970        let (tx, rx) = oneshot::channel();
1971        self.cmd_tx
1972            .send(TorrentCommand::Flags { reply: tx })
1973            .await
1974            .map_err(|_| crate::Error::Shutdown)?;
1975        rx.await.map_err(|_| crate::Error::Shutdown)
1976    }
1977
1978    /// Set (enable) the specified torrent flags.
1979    ///
1980    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1981    ///
1982    /// # Errors
1983    ///
1984    /// Returns an error if the session is shut down.
1985    pub async fn set_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1986        let (tx, rx) = oneshot::channel();
1987        self.cmd_tx
1988            .send(TorrentCommand::SetFlags { flags, reply: tx })
1989            .await
1990            .map_err(|_| crate::Error::Shutdown)?;
1991        rx.await.map_err(|_| crate::Error::Shutdown)
1992    }
1993
1994    /// Unset (disable) the specified torrent flags.
1995    ///
1996    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1997    ///
1998    /// # Errors
1999    ///
2000    /// Returns an error if the session is shut down.
2001    pub async fn unset_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
2002        let (tx, rx) = oneshot::channel();
2003        self.cmd_tx
2004            .send(TorrentCommand::UnsetFlags { flags, reply: tx })
2005            .await
2006            .map_err(|_| crate::Error::Shutdown)?;
2007        rx.await.map_err(|_| crate::Error::Shutdown)
2008    }
2009
2010    /// Immediately initiate a peer connection to the given address.
2011    ///
2012    /// Bypasses the normal peer selection queue — the connection attempt starts
2013    /// right away. Fire-and-forget: no reply is sent.
2014    ///
2015    /// # Errors
2016    ///
2017    /// Returns an error if the connection or binding fails.
2018    pub async fn connect_peer(&self, addr: SocketAddr) -> crate::Result<()> {
2019        self.cmd_tx
2020            .send(TorrentCommand::ConnectPeer { addr })
2021            .await
2022            .map_err(|_| crate::Error::Shutdown)
2023    }
2024
2025    /// M147: Send pre-resolved metadata from the background resolver.
2026    ///
2027    /// Fire-and-forget: uses `try_send` to avoid blocking the resolver task.
2028    /// If the channel is full or closed, the pre-resolved metadata is silently
2029    /// discarded (the `TorrentActor`'s own `FetchingMetadata` phase will handle it).
2030    pub fn send_pre_resolved_metadata(&self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
2031        let _ = self
2032            .cmd_tx
2033            .try_send(TorrentCommand::PreResolvedMetadata { info_bytes, peers });
2034    }
2035
2036    /// **TEST-ONLY (v0.173.2).** Synchronous counterpart to
2037    /// [`Self::send_pre_resolved_metadata`] that waits for the actor to
2038    /// finish processing before returning.
2039    ///
2040    /// Uses backpressured `cmd_tx.send().await` plus a oneshot completion
2041    /// ack, so `test_inject_metadata(...).await` resolves only after the
2042    /// metadata has been processed by the `TorrentActor`. See
2043    /// `TorrentCommand::TestInjectMetadata` for the rationale.
2044    ///
2045    /// # Errors
2046    /// - [`crate::Error::Shutdown`] if the torrent command channel is closed.
2047    ///
2048    /// # Visibility
2049    /// `pub` (not `pub(crate)`) because the sole caller is `SessionActor::run`
2050    /// in `irontide-session` — a *different* crate since the M244 split moved
2051    /// `torrent.rs` here. Still gated behind `test-util`, so it only exists in
2052    /// test builds; `irontide-session/test-util` forwards to
2053    /// `irontide-engine/test-util` to keep the call site and definition in sync.
2054    #[cfg(feature = "test-util")]
2055    pub async fn test_inject_metadata(&self, info_bytes: Vec<u8>) -> crate::Result<()> {
2056        let (tx, rx) = tokio::sync::oneshot::channel();
2057        self.cmd_tx
2058            .send(TorrentCommand::TestInjectMetadata {
2059                info_bytes,
2060                reply: tx,
2061            })
2062            .await
2063            .map_err(|_| crate::Error::Shutdown)?;
2064        rx.await.map_err(|_| crate::Error::Shutdown)?;
2065        Ok(())
2066    }
2067}
2068
2069// ---------------------------------------------------------------------------
2070// M116: Cached file metadata for zero-allocation piece completion checks
2071// ---------------------------------------------------------------------------
2072
2073/// Pre-computed file metadata for zero-allocation piece completion checks.
2074#[derive(Debug, Clone)]
2075pub(crate) struct CachedFileEntry {
2076    pub(crate) index: usize,
2077    #[allow(dead_code)] // Used in tests; retained for future diagnostics
2078    pub(crate) length: u64,
2079    pub(crate) first_piece: u32,
2080    pub(crate) last_piece: u32,
2081}
2082
2083/// Cached file-to-piece mapping, computed once at torrent registration.
2084#[derive(Debug, Clone)]
2085pub(crate) struct CachedFileInfo {
2086    pub(crate) entries: Vec<CachedFileEntry>,
2087}
2088
2089pub(crate) fn build_cached_file_info(meta: &TorrentMetaV1, lengths: &Lengths) -> CachedFileInfo {
2090    let piece_length = lengths.piece_length();
2091    let files = meta.info.files();
2092    let mut entries = Vec::with_capacity(files.len());
2093    let mut offset = 0u64;
2094    for (index, file) in files.iter().enumerate() {
2095        let first_piece = (offset / piece_length) as u32;
2096        let last_piece = if file.length == 0 {
2097            first_piece
2098        } else {
2099            ((offset + file.length - 1) / piece_length) as u32
2100        };
2101        entries.push(CachedFileEntry {
2102            index,
2103            length: file.length,
2104            first_piece,
2105            last_piece,
2106        });
2107        offset += file.length;
2108    }
2109    CachedFileInfo { entries }
2110}
2111
2112// ---------------------------------------------------------------------------
2113// TorrentActor — internal single-owner event loop
2114// ---------------------------------------------------------------------------
2115
2116pub(crate) struct TorrentActor {
2117    pub(crate) config: TorrentConfig,
2118    /// M120: Lock timing settings for hot-path diagnostics.
2119    pub(crate) lock_timing: crate::timed_lock::LockTimingSettings,
2120    pub(crate) info_hash: Id20,
2121    pub(crate) our_peer_id: Id20,
2122    pub(crate) state: TorrentState,
2123
2124    // Disk I/O (None in magnet mode until metadata arrives)
2125    pub(crate) disk: Option<DiskHandle>,
2126    pub(crate) disk_manager: DiskManagerHandle,
2127    pub(crate) chunk_tracker: Option<ChunkTracker>,
2128    pub(crate) lengths: Option<Lengths>,
2129    pub(crate) num_pieces: u32,
2130
2131    // Piece management
2132    pub(crate) file_priorities: Vec<FilePriority>,
2133    pub(crate) wanted_pieces: Bitfield,
2134    pub(crate) end_game: EndGame,
2135
2136    // Streaming (M28)
2137    pub(crate) streaming_pieces: BTreeSet<u32>,
2138    pub(crate) time_critical_pieces: BTreeSet<u32>,
2139    pub(crate) streaming_cursors: Vec<crate::streaming::StreamingCursor>,
2140    pub(crate) piece_ready_tx: broadcast::Sender<u32>,
2141    pub(crate) have_watch_tx: tokio::sync::watch::Sender<Bitfield>,
2142    pub(crate) have_watch_rx: tokio::sync::watch::Receiver<Bitfield>,
2143    pub(crate) stream_read_semaphore: Arc<tokio::sync::Semaphore>,
2144
2145    // Peer management
2146    pub(crate) peers: HashMap<SocketAddr, PeerState>,
2147    /// Per-(SocketAddr × torrent) cumulative time we had each peer
2148    /// unchoked. Survives reconnects: when a `PeerState` is dropped on
2149    /// disconnect, its `unchoke_duration_total` is flushed into this map
2150    /// keyed by the peer's `SocketAddr`. Reads via
2151    /// `SessionHandle::peer_unchoke_durations` sum the persistent value
2152    /// here with each currently-live `PeerState`'s in-flight accumulator.
2153    /// Used by the libtorrent-mirror `optimistic_unchoke_fairness` perf
2154    /// scenario to assert the choker rotates upload slots fairly.
2155    pub(crate) unchoke_durations: HashMap<SocketAddr, Duration>,
2156    /// Cached peer download rates for piece stealing decisions.
2157    /// Refreshed on each periodic tick (~1s) instead of rebuilding per block.
2158    pub(crate) cached_peer_rates: FxHashMap<SocketAddr, f64>,
2159    /// Notify handle for reactive queue refill (legacy, unused in M73).
2160    #[allow(dead_code)]
2161    pub(crate) refill_notify: Arc<tokio::sync::Notify>,
2162    /// M93: Lock-free piece states (shared with peers via Arc).
2163    pub(crate) atomic_states: Option<Arc<crate::piece_reservation::AtomicPieceStates>>,
2164    /// M103: Shared block-level request/received bitmaps.
2165    pub(crate) block_maps: Option<Arc<BlockMaps>>,
2166    /// M103: Shared queue of stealable pieces.
2167    pub(crate) steal_candidates: Option<Arc<StealCandidates>>,
2168    /// M132: Last time we populated the steal queue with in-flight pieces.
2169    pub(crate) last_steal_populate: Instant,
2170    /// M120: Per-piece write guards to prevent steal/write races.
2171    pub(crate) piece_write_guards: Option<Arc<crate::piece_reservation::PieceWriteGuards>>,
2172    /// v0.173.3: Reusable buffer for `soft_reap_candidates` output.
2173    /// Reaped every `soft_reap_interval` tick (~1/s) with typical size
2174    /// 0-16 entries; held here so the allocation is reused across ticks.
2175    pub(crate) soft_reap_buf: Vec<std::net::SocketAddr>,
2176    /// v0.187.3 / 3A: sliding-window of recent proactive eviction timestamps.
2177    /// Entries older than 60s are pruned on each tick; the actor refuses to
2178    /// evict when the post-prune length is >= `proactive_evictions_per_minute_limit`.
2179    /// Prevents the 130 → 20-50 churn observed in the dogfood report.
2180    pub(crate) eviction_history: std::collections::VecDeque<std::time::Instant>,
2181    /// v0.187.3 / Bug 8a: when set, the main loop runs the choker on the
2182    /// next iteration regardless of `unchoke_interval`. Set on transition
2183    /// INTO `Seeding` so interested peers see Unchoke promptly. Cleared
2184    /// after the next `run_choker().await`.
2185    pub(crate) force_immediate_choker_tick: bool,
2186    /// M187: Actor-owned piece dispatch tracker (direct-acquire model).
2187    pub(crate) piece_tracker: Option<PieceTracker>,
2188    /// M187: Watch sender that broadcasts `PieceOrderMap` to peer tasks.
2189    pub(crate) order_map_tx: tokio::sync::watch::Sender<Arc<PieceOrderMap>>,
2190
2191    /// M246: set by the `SetFilePriority` arm to request a coalesced order-map
2192    /// rebuild on the next 1 s pipeline tick — a batch of M priority changes
2193    /// sets it M times but triggers ONE rebuild. Cleared by
2194    /// `rebuild_order_map_now`.
2195    pub(crate) order_map_dirty: bool,
2196
2197    /// M246: actor-owned monotone generation counter for the published
2198    /// `PieceOrderMap`. Every rebuild assigns `next_order_map_gen += 1`; the
2199    /// generation is NEVER derived from the published `watch` value — under any
2200    /// async publish that would let two back-to-back batch rebuilds read the
2201    /// same generation and drop one (the rejected-Candidate-H bug class).
2202    pub(crate) next_order_map_gen: u64,
2203    /// M93: Maps piece index -> peer slab slot that owns it.
2204    pub(crate) piece_owner: Vec<Option<u16>>,
2205    /// M93: Arena-allocated peer tracking: slot <-> `SocketAddr`.
2206    pub(crate) peer_slab: crate::piece_reservation::PeerSlab,
2207    #[allow(dead_code)]
2208    pub(crate) priority_pieces: BTreeSet<u32>,
2209    /// M93: Maximum in-flight pieces.
2210    pub(crate) max_in_flight: usize,
2211    /// Piece notify handle (for driver spawning).
2212    pub(crate) reservation_notify: Option<Arc<tokio::sync::Notify>>,
2213    /// Dispatch state snapshot at the previous pipeline tick:
2214    /// `(queue_pieces.count_ones(), inflight.len())`. The tick uses this to
2215    /// gate its `notify_waiters()` safety-net call — if neither value has
2216    /// changed, no peer needs waking, and waking all of them would just
2217    /// trigger spurious acquire calls (the 93% `NoneAvailable` rate measured
2218    /// on 2026-05-11 was largely driven by unconditional 1 Hz wake spam).
2219    /// `None` until the first tick records a baseline.
2220    pub(crate) last_tick_dispatch_state: Option<(u32, usize)>,
2221    pub(crate) choker: Choker,
2222    /// M159: User-requested seed-only mode flag.
2223    ///
2224    /// When `true`, the actor stops issuing new block requests (gating the
2225    /// `StartRequesting` dispatch sent to peer tasks) and cancels any
2226    /// in-flight requests. Uploads continue unaffected. Distinct from the
2227    /// naturally-complete seeding state tracked by `state == Seeding`.
2228    pub(crate) user_seed_mode: bool,
2229    /// Whether the user force-started this torrent (bypassing queue limits).
2230    pub(crate) user_forced: bool,
2231    /// Per-torrent connection limit override (0 = use `config.max_peers`).
2232    pub(crate) max_connections: usize,
2233    /// M137: Unified peer lifecycle tracker (replaces `peers_connected` + `connect_backoff` + `peer_tx` + `unique_peers_attempted`).
2234    pub(crate) peer_states: Option<Arc<crate::peer_states::PeerStates>>,
2235    /// M147: `ConnectPool` semaphore — gates connection attempts only.
2236    /// Permits are released on `HandshakeComplete` (not held for peer lifetime).
2237    pub(crate) connect_semaphore: Arc<tokio::sync::Semaphore>,
2238    /// M147: Maps peer address → permit holder. Permits are taken on `HandshakeComplete`
2239    /// to free `ConnectPool` slots. RAII cleanup on task exit handles failure cases.
2240    pub(crate) connect_permits:
2241        HashMap<SocketAddr, Arc<parking_lot::Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>>,
2242    /// M107: Receiver for connect requests from the adder task.
2243    pub(crate) connect_rx: Option<mpsc::Receiver<ConnectPeer>>,
2244
2245    // Metadata (for magnet links)
2246    pub(crate) metadata_downloader: Option<MetadataDownloader>,
2247
2248    // Parsed torrent meta (for piece hash verification)
2249    pub(crate) meta: Option<TorrentMetaV1>,
2250
2251    /// M116: Pre-computed file->piece mapping for zero-alloc completion checks.
2252    pub(crate) cached_files: Option<CachedFileInfo>,
2253
2254    // Stats
2255    pub(crate) downloaded: u64,
2256    pub(crate) uploaded: u64,
2257    pub(crate) checking_progress: f32,
2258    pub(crate) total_download: u64,
2259    pub(crate) total_upload: u64,
2260    pub(crate) total_failed_bytes: u64,
2261    pub(crate) total_redundant_bytes: u64,
2262    pub(crate) added_time: i64,
2263    pub(crate) completed_time: i64,
2264    pub(crate) last_download: i64,
2265    pub(crate) last_upload: i64,
2266    pub(crate) last_seen_complete: i64,
2267    pub(crate) active_duration: i64,
2268    pub(crate) finished_duration: i64,
2269    pub(crate) seeding_duration: i64,
2270    pub(crate) active_since: Option<std::time::Instant>,
2271    pub(crate) state_duration_since: Option<std::time::Instant>,
2272    #[allow(dead_code)] // M104: ConnectPhase removed; kept for future diagnostics
2273    pub(crate) started_at: std::time::Instant,
2274    pub(crate) moving_storage: bool,
2275    pub(crate) has_incoming: bool,
2276    pub(crate) need_save_resume: bool,
2277    pub(crate) error: String,
2278    pub(crate) error_file: i32,
2279
2280    // Channels
2281    pub(crate) cmd_rx: mpsc::Receiver<TorrentCommand>,
2282    pub(crate) event_tx: mpsc::Sender<PeerEvent>,
2283    pub(crate) event_rx: mpsc::Receiver<PeerEvent>,
2284
2285    // Async disk pipeline channels
2286    pub(crate) write_error_rx: mpsc::Receiver<crate::disk::DiskWriteError>,
2287    pub(crate) write_error_tx: mpsc::Sender<crate::disk::DiskWriteError>,
2288    pub(crate) verify_result_rx: mpsc::Receiver<crate::disk::VerifyResult>,
2289    pub(crate) verify_result_tx: mpsc::Sender<crate::disk::VerifyResult>,
2290    /// Pieces currently awaiting async verification — prevents duplicate
2291    /// verify tasks when end game or slow peers deliver duplicate blocks.
2292    pub(crate) pending_verify: HashSet<u32>,
2293    /// Generation counter per piece — increments on release/re-reserve.
2294    /// Used to detect stale hash results from the `HashPool` (M96).
2295    pub(crate) piece_generations: Vec<u64>,
2296    /// Receiver for hash pool results (M96).
2297    pub(crate) hash_result_rx: tokio::sync::mpsc::Receiver<crate::hash_pool::HashResult>,
2298    /// Sender for hash pool results — cloned into `DiskHandle` (M96).
2299    pub(crate) hash_result_tx: tokio::sync::mpsc::Sender<crate::hash_pool::HashResult>,
2300
2301    // TCP listener for incoming peer connections
2302    pub(crate) listener: Option<Box<dyn crate::transport::TransportListener>>,
2303
2304    // uTP socket for outbound connections (shared with session, cloned)
2305    pub(crate) utp_socket: Option<irontide_utp::UtpSocket>,
2306    // IPv6 uTP socket for outbound connections to IPv6 peers
2307    pub(crate) utp_socket_v6: Option<irontide_utp::UtpSocket>,
2308
2309    // Tracker management
2310    pub(crate) tracker_manager: TrackerManager,
2311    /// M143: Receiver for streaming tracker announce results.
2312    /// `Some` while a background announce is in-flight, `None` when idle.
2313    pub(crate) tracker_result_rx: Option<mpsc::Receiver<crate::tracker_manager::TrackerPeerBatch>>,
2314
2315    // DHT handles (shared, optional). M173 Lane B (B6): subscribe to
2316    // the session-level DhtBroadcast so a runtime DHT restart is
2317    // observed on the next call without holding a stale clone. The
2318    // `dht_enabled` flag gates BEP 27 private torrents (which must
2319    // not announce to DHT regardless of session-level enable) plus
2320    // any per-torrent opt-out.
2321    pub(crate) dht_rx: irontide_dht::DhtReceiver,
2322    pub(crate) dht_v6_rx: irontide_dht::DhtReceiver,
2323    pub(crate) dht_enabled: bool,
2324    pub(crate) dht_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2325    pub(crate) dht_v6_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2326    /// Consecutive times the V6 DHT returned an empty table.
2327    /// After 30 failures (~3s at 100ms), stop retrying to avoid log spam.
2328    pub(crate) dht_v6_empty_count: u32,
2329    /// Timestamp of last V6 DHT retry attempt (M97).
2330    pub(crate) dht_v6_last_retry: Option<std::time::Instant>,
2331
2332    // Alert system (M15)
2333    pub(crate) alert_tx: broadcast::Sender<Alert>,
2334    pub(crate) alert_mask: Arc<AtomicU32>,
2335
2336    // Rate limiting (M14)
2337    pub(crate) upload_bucket: crate::rate_limiter::TokenBucket,
2338    pub(crate) download_bucket: SharedBucket,
2339    pub(crate) global_upload_bucket: Option<SharedBucket>,
2340    #[allow(dead_code)] // M73: rate limiting deferred to M74
2341    pub(crate) global_download_bucket: Option<SharedBucket>,
2342    pub(crate) slot_tuner: crate::slot_tuner::SlotTuner,
2343    pub(crate) upload_bytes_interval: u64,
2344
2345    /// Peak aggregate download rate observed (bytes/sec), for peer turnover cutoff.
2346    pub(crate) peak_download_rate: u64,
2347
2348    // Web seeding (M22)
2349    pub(crate) web_seeds: HashMap<String, mpsc::Sender<crate::web_seed::WebSeedCommand>>,
2350    pub(crate) banned_web_seeds: HashSet<String>,
2351    pub(crate) web_seed_in_flight: HashMap<u32, String>,
2352    /// M178: Per-URL stats accumulated from `PeerEvent::WebSeedProgress`
2353    /// events emitted by `WebSeedTask`. Persisted to fast-resume so stats
2354    /// survive app restart (see `resume_file.rs`).
2355    pub(crate) web_seed_stats: HashMap<String, irontide_core::WebSeedStats>,
2356    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2357    /// via PEX (BEP 11) since this actor started. Surfaced through
2358    /// `SessionHandle::pex_peer_count` for the qBt v2 trackers endpoint
2359    /// and the GUI Trackers tab pseudo-tracker rows. Reset on torrent
2360    /// removal (actor lifecycle).
2361    pub(crate) pex_peer_count: usize,
2362    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2363    /// via LSD (BEP 14) multicast. Self-cookie filtering happens upstream
2364    /// in M174's session-level LSD path.
2365    pub(crate) lsd_peer_count: usize,
2366
2367    // BEP 16 super seeding (M23)
2368    pub(crate) super_seed: Option<crate::super_seed::SuperSeedState>,
2369    // M118: Broadcast channel for Have distribution (replaces HaveBuffer)
2370    pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender<u32>,
2371
2372    /// M44: pieces we've suggested to each peer (avoid re-suggesting)
2373    pub(crate) suggested_to_peers: HashMap<SocketAddr, HashSet<u32>>,
2374
2375    /// M44: pieces for which we've already sent predictive Have
2376    pub(crate) predictive_have_sent: HashSet<u32>,
2377
2378    // Smart banning (M25)
2379    pub(crate) ban_manager: irontide_session_types::SharedBanManager,
2380    pub(crate) piece_contributors: HashMap<u32, HashSet<std::net::IpAddr>>,
2381    pub(crate) parole_pieces: HashMap<u32, crate::ban::ParoleState>,
2382
2383    // IP filtering (M29)
2384    pub(crate) ip_filter: irontide_session_types::SharedIpFilter,
2385
2386    // BEP 40 peer priority (M32b)
2387    pub(crate) external_ip: Option<std::net::IpAddr>,
2388
2389    // Share mode (M32c): LRU tracker for in-memory piece relay.
2390    // Tracks which pieces are currently "live" (servable) in share mode.
2391    // Oldest pieces are evicted when capacity is reached.
2392    pub(crate) share_lru: std::collections::VecDeque<u32>,
2393    /// Max pieces to keep live in share mode (0 = share mode disabled).
2394    pub(crate) share_max_pieces: usize,
2395
2396    // Extension plugins (M32d)
2397    pub(crate) plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
2398
2399    // BEP 52 v2/hybrid support (M34-M35)
2400    pub(crate) hash_picker: Option<irontide_core::HashPicker>,
2401    pub(crate) version: irontide_core::TorrentVersion,
2402    #[allow(dead_code)] // stored for hybrid torrent re-serialization (M35 Task 5)
2403    pub(crate) meta_v2: Option<irontide_core::TorrentMetaV2>,
2404
2405    /// Full info hashes for dual-swarm support (v1 + v2 for hybrid).
2406    pub(crate) info_hashes: irontide_core::InfoHashes,
2407
2408    /// Dual-swarm DHT peer receivers (v2 hash in hybrid torrents).
2409    pub(crate) dht_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2410    pub(crate) dht_v6_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2411
2412    /// BEP 53: deferred file selection from magnet `so=` parameter.
2413    /// Applied after metadata is received to set file priorities.
2414    pub(crate) magnet_selected_files: Option<Vec<irontide_core::FileSelection>>,
2415
2416    /// I2P SAM session for anonymous peer connections (M41).
2417    pub(crate) sam_session: Option<Arc<crate::i2p::SamSession>>,
2418
2419    /// Receiver for incoming I2P peer connections (M41).
2420    pub(crate) i2p_accept_rx: Option<mpsc::Receiver<crate::i2p::SamStream>>,
2421
2422    /// Counter for generating synthetic `SocketAddr` values for I2P peers (M41).
2423    pub(crate) i2p_peer_counter: u32,
2424
2425    /// Maps synthetic `SocketAddr` → `I2pDestination` for outbound I2P connects.
2426    pub(crate) i2p_destinations: HashMap<SocketAddr, crate::i2p::I2pDestination>,
2427
2428    /// SSL manager for SSL torrent certificate handling (M42).
2429    pub(crate) ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
2430
2431    /// Per-class rate limiting with mixed-mode (M45).
2432    pub(crate) rate_limiter_set: crate::rate_limiter::RateLimiterSet,
2433    /// Whether auto-sequential mode is currently active (hysteresis state).
2434    pub(crate) auto_sequential_active: bool,
2435    /// Network transport factory for TCP operations (M51).
2436    pub(crate) factory: Arc<crate::transport::NetworkFactory>,
2437    /// Shared hash pool for parallel piece verification (M96).
2438    pub(crate) hash_pool_ref: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
2439    /// M108: Shared snapshot of connected peer addresses for PEX send tasks.
2440    pub(crate) live_outgoing_peers:
2441        std::sync::Arc<parking_lot::RwLock<std::collections::HashMap<SocketAddr, u8>>>,
2442    /// M108: Total outbound connection attempts dispatched to peer adder.
2443    pub(crate) connect_attempts: u64,
2444    /// M108: Total connection failures (peers that disconnected).
2445    pub(crate) connect_failures: u64,
2446    /// M138: Total number of peers evicted by proactive choke rotation.
2447    pub(crate) choke_rotations: u64,
2448    /// M149: When each in-flight piece started downloading. Indexed by piece index.
2449    /// Set when `piece_owner` assigns a piece, cleared on verify/hash-fail.
2450    pub(crate) inflight_started: Vec<Option<Instant>>,
2451    /// M149: Rolling window of recent piece completion times for steal threshold.
2452    pub(crate) completed_piece_times: std::collections::VecDeque<Duration>,
2453    /// M149: Total number of piece-level steals performed.
2454    pub(crate) piece_steals: u64,
2455    /// M190: Total holepunch rendezvous requests we relayed.
2456    pub(crate) holepunch_relayed: u64,
2457    /// M190: Per-peer rate limit for holepunch rendezvous requests.
2458    pub(crate) holepunch_relay_rate: HashMap<SocketAddr, (Instant, u32)>,
2459    /// M112: Tracks recent holepunch attempts to prevent retry storms.
2460    pub(crate) holepunch_cooldowns: HashMap<SocketAddr, Instant>,
2461    /// M112: Buffer for holepunch attempts (`disconnect_peer` is sync, `try_holepunch` is async).
2462    pub(crate) holepunch_pending: Vec<SocketAddr>,
2463    /// Sim-perf engine surface: shared session counters used by
2464    /// `rebuild_availability_snapshot` to track Allow / Defer rates,
2465    /// and by per-peer spawn sites to seed `PeerShared::counters`.
2466    pub(crate) counters: Arc<crate::stats::SessionCounters>,
2467}
2468
2469/// Maximum number of in-flight end-game requests per peer.
2470/// libtorrent continues full pipelining in end-game; we use a moderate
2471/// depth so that round-trip latency doesn't bottleneck throughput.
2472/// End-game pipeline depth: match normal mode (128 slots per peer).
2473/// Safe because the reactive per-block cascade was replaced with a 200ms
2474/// batch refill tick — raising depth no longer amplifies picker invocations.
2475pub(crate) const END_GAME_DEPTH: usize = 128;
2476
2477/// Minimum free pipeline slots before invoking the full piece picker in
2478/// `handle_piece_data()`.  Avoids running the 5-layer picker on every single
2479impl TorrentActor {
2480    /// Returns the current IPv4 `DhtHandle`, or `None` if DHT is disabled
2481    /// for this torrent (BEP 27 private, per-torrent opt-out, or
2482    /// session-level disable). M173 Lane B (B6): reads from the
2483    /// session-level [`irontide_dht::DhtBroadcast`] receiver, so a
2484    /// runtime DHT restart (B11) is observed transparently here.
2485    pub(crate) fn current_dht(&self) -> Option<irontide_dht::DhtHandle> {
2486        if self.dht_enabled {
2487            self.dht_rx.current()
2488        } else {
2489            None
2490        }
2491    }
2492
2493    /// Returns the current IPv6 `DhtHandle`, or `None` if DHT is disabled.
2494    /// See [`Self::current_dht`].
2495    pub(crate) fn current_dht_v6(&self) -> Option<irontide_dht::DhtHandle> {
2496        if self.dht_enabled {
2497            self.dht_v6_rx.current()
2498        } else {
2499            None
2500        }
2501    }
2502
2503    /// Hold-window helper: wait up to `hold` for the IPv4 DHT broadcast
2504    /// to deliver a non-`None` handle. Returns `None` if the wait
2505    /// times out OR if DHT is disabled for this torrent.
2506    ///
2507    /// Used by call sites that issue requests during a DHT restart
2508    /// window and prefer to hold a brief moment rather than fail
2509    /// immediately. The hold is bounded — callers must not loop on
2510    /// `None`, since a permanently-disabled DHT will hit the timeout
2511    /// every iteration.
2512    ///
2513    /// # Errors
2514    ///
2515    /// Returns `None` on timeout, on disabled DHT, or if the
2516    /// broadcast sender has been dropped (session shutting down).
2517    #[allow(dead_code)] // wired by future per-call-site refactors as needed
2518    pub(crate) async fn current_dht_or_wait(
2519        &mut self,
2520        hold: std::time::Duration,
2521    ) -> Option<irontide_dht::DhtHandle> {
2522        if !self.dht_enabled {
2523            return None;
2524        }
2525        if let Some(handle) = self.dht_rx.current() {
2526            return Some(handle);
2527        }
2528        // Wait for the broadcast to fire `replace(Some(_))`.
2529        match tokio::time::timeout(hold, self.dht_rx.changed()).await {
2530            Ok(Ok(())) => self.dht_rx.current(),
2531            Ok(Err(_)) | Err(_) => None,
2532        }
2533    }
2534
2535    /// Main event loop.
2536    async fn run(mut self) {
2537        // Verify existing pieces on startup (resume support)
2538        self.verify_existing_pieces().await;
2539
2540        // M93: Initialize lock-free piece states after verification
2541        // so we_have reflects already-verified pieces.
2542        if let Some(ct) = &self.chunk_tracker {
2543            let atomic_states = Arc::new(AtomicPieceStates::new(
2544                self.num_pieces,
2545                ct.bitfield(),
2546                &self.wanted_pieces,
2547            ));
2548            self.atomic_states = Some(Arc::clone(&atomic_states));
2549            self.piece_owner = vec![None; self.num_pieces as usize];
2550            // M149: Initialize inflight tracking
2551            self.inflight_started = vec![None; self.num_pieces as usize];
2552            self.max_in_flight = self.config.max_in_flight_pieces;
2553
2554            // M103: Initialize block stealing infrastructure
2555            if self.config.use_block_stealing {
2556                if let Some(ref lengths) = self.lengths {
2557                    self.block_maps = Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
2558                }
2559                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
2560            }
2561            // M120: Per-piece write guards
2562            self.piece_write_guards = Some(Arc::new(
2563                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
2564            ));
2565
2566            // M187: Init direct-acquire dispatch state.
2567            self.piece_tracker = Some(PieceTracker::new(
2568                self.num_pieces,
2569                ct.bitfield(),
2570                &self.wanted_pieces,
2571            ));
2572            if let Some(ref cached) = self.cached_files {
2573                let file_piece_ranges: Vec<(u32, u32)> = cached
2574                    .entries
2575                    .iter()
2576                    .map(|e| (e.first_piece, e.last_piece))
2577                    .collect();
2578                let om = Arc::new(PieceOrderMap::build(
2579                    &self.file_priorities,
2580                    &file_piece_ranges,
2581                    self.num_pieces,
2582                    0,
2583                ));
2584                self.order_map_tx.send_replace(om);
2585            }
2586
2587            let notify = Arc::new(tokio::sync::Notify::new());
2588            self.reservation_notify = Some(notify);
2589        }
2590
2591        // Spawn web seeds if not already seeding
2592        if self.state != TorrentState::Seeding {
2593            self.spawn_web_seeds();
2594            self.assign_pieces_to_web_seeds();
2595        }
2596
2597        // M147: Set up ConnectPool — semaphore gates connection attempts only.
2598        // Permits are released on HandshakeComplete, not held for peer lifetime.
2599        let connect_semaphore = Arc::new(tokio::sync::Semaphore::new(
2600            self.effective_max_connections(),
2601        ));
2602        self.connect_semaphore = Arc::clone(&connect_semaphore);
2603        self.connect_permits.clear();
2604        // M137: Create PeerStates with the adder's input channel.
2605        // v0.187.3: pull eviction-ban cap + duration from session settings so
2606        // user changes via apply_settings take effect on the next spawn.
2607        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
2608        let peer_states = Arc::new(crate::peer_states::PeerStates::new_with_config(
2609            queue_tx,
2610            self.config.eviction_ban_set_cap,
2611            std::time::Duration::from_secs(self.config.eviction_ban_duration_secs),
2612        ));
2613        self.peer_states = Some(Arc::clone(&peer_states));
2614        let (adder_connect_tx, adder_connect_rx) = mpsc::channel(64);
2615        self.connect_rx = Some(adder_connect_rx);
2616        // M147: ConnectPool semaphore gates connection attempts (released on handshake)
2617        tokio::spawn(peer_adder::peer_adder_task(
2618            queue_rx,
2619            Arc::clone(&connect_semaphore),
2620            Arc::clone(&peer_states),
2621            Arc::clone(&self.ban_manager),
2622            Arc::clone(&self.ip_filter),
2623            adder_connect_tx,
2624        ));
2625
2626        let mut unchoke_interval = tokio::time::interval(Duration::from_secs(10));
2627        let mut rate_interval = tokio::time::interval(Duration::from_secs(2));
2628        rate_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2629        let mut optimistic_interval = tokio::time::interval(Duration::from_secs(30));
2630        let mut refill_interval = tokio::time::interval(Duration::from_millis(100));
2631        let mut dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::ZERO));
2632        let mut suggest_interval = if self.config.suggest_mode {
2633            Some(tokio::time::interval(Duration::from_secs(30)))
2634        } else {
2635            None
2636        };
2637        // M136: 1s steal-queue maintenance tick.
2638        let mut turnover_interval = tokio::time::interval(Duration::from_secs(1));
2639        let mut pipeline_tick_interval = tokio::time::interval(Duration::from_secs(1));
2640        // M77: Skip missed ticks — safety-net notify should fire at most once/second
2641        pipeline_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2642        let mut end_game_tick_interval = tokio::time::interval(Duration::from_millis(200));
2643        end_game_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2644        let mut diag_interval = tokio::time::interval(Duration::from_secs(5));
2645        // M108: 30s connection success rate summary for variance diagnosis.
2646        let mut conn_stats_interval = tokio::time::interval(Duration::from_secs(30));
2647        // M107: 5s metadata piece timeout — only meaningful in FetchingMetadata state
2648        let mut metadata_timeout_interval = tokio::time::interval(Duration::from_secs(5));
2649        // M103: 50ms debounce for reactive snapshot (was 500ms fixed interval)
2650        // M147: 1s soft reap interval — disconnects connecting peers without TCP SYN-ACK
2651        let mut soft_reap_interval = tokio::time::interval(Duration::from_secs(1));
2652        // M148: 2s proactive eviction — breaks the catch-22 where LivePool fills with
2653        // deadweight and no HandshakeComplete events arrive to trigger eviction.
2654        let mut eviction_interval = tokio::time::interval(Duration::from_secs(2));
2655        eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2656
2657        // Don't fire immediately for the first tick
2658        unchoke_interval.tick().await;
2659        optimistic_interval.tick().await;
2660        refill_interval.tick().await;
2661        // Note: dht_requery_sleep uses Sleep (not Interval), no initial tick skip needed
2662        if let Some(ref mut si) = suggest_interval {
2663            si.tick().await; // skip initial tick
2664        }
2665        turnover_interval.tick().await;
2666        pipeline_tick_interval.tick().await;
2667        end_game_tick_interval.tick().await;
2668        diag_interval.tick().await;
2669        conn_stats_interval.tick().await;
2670        metadata_timeout_interval.tick().await;
2671        soft_reap_interval.tick().await;
2672        eviction_interval.tick().await;
2673
2674        // Initial tracker announce (Started event) — non-blocking, fires via select! arm
2675        // DHT announce (v4 + v6) — dual-swarm for hybrid torrents
2676        if self.state == TorrentState::Downloading && self.config.enable_dht {
2677            // Primary hash (v1 or best_v1)
2678            if let Some(dht) = self.current_dht()
2679                && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
2680            {
2681                warn!("DHT v4 announce failed: {e}");
2682            }
2683            if let Some(dht6) = self.current_dht_v6()
2684                && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
2685            {
2686                debug!("DHT v6 announce failed: {e}");
2687            }
2688            // Dual-swarm: also announce v2 hash (truncated) for hybrid torrents
2689            if self.info_hashes.is_hybrid()
2690                && let Some(v2) = self.info_hashes.v2
2691            {
2692                let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
2693                if v2_as_v1 != self.info_hash {
2694                    if let Some(dht) = self.current_dht()
2695                        && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
2696                    {
2697                        debug!("DHT v4 dual-swarm announce failed: {e}");
2698                    }
2699                    if let Some(dht6) = self.current_dht_v6()
2700                        && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
2701                    {
2702                        debug!("DHT v6 dual-swarm announce failed: {e}");
2703                    }
2704                }
2705            }
2706        }
2707
2708        // I2P accept loop: spawn a background task that feeds incoming I2P
2709        // connections back via a channel, so the select! arm can handle them.
2710        if self.config.enable_i2p
2711            && let Some(ref sam) = self.sam_session
2712        {
2713            let (tx, rx) = mpsc::channel(16);
2714            let sam = Arc::clone(sam);
2715            tokio::spawn(async move {
2716                loop {
2717                    match sam.accept().await {
2718                        Ok(stream) => {
2719                            if tx.send(stream).await.is_err() {
2720                                break; // torrent actor dropped
2721                            }
2722                        }
2723                        Err(e) => {
2724                            warn!("I2P accept error: {e}");
2725                            tokio::time::sleep(Duration::from_secs(5)).await;
2726                        }
2727                    }
2728                }
2729            });
2730            self.i2p_accept_rx = Some(rx);
2731        }
2732
2733        loop {
2734            tokio::select! {
2735                biased;
2736                // Events from peers — batch-drain to reduce select! overhead.
2737                // At 100 MB/s we get ~6K events/sec; processing one-by-one
2738                // means 6K select! iterations with waker re-registration.
2739                // biased; ensures this high-throughput arm is checked first.
2740                event = self.event_rx.recv() => {
2741                    if let Some(event) = event {
2742                        // M182: ping the per-peer event_drain Notify so
2743                        // the reader's BackpressureQueue retries any
2744                        // spilled events. Looked up from peers by
2745                        // event.peer_addr (most variants carry one);
2746                        // events without a peer (PexPeers,
2747                        // TrackersReceived, WebSeed*) skip the ping —
2748                        // they don't fill the per-peer event channel.
2749                        Self::ping_event_drain(&self.peers, &event);
2750                        self.handle_peer_event(event)
2751                            .instrument(tracing::debug_span!("handle_peer_event"))
2752                            .await;
2753                        // Drain up to 512 more ready events without re-entering select!
2754                        for _ in 0..512 {
2755                            match self.event_rx.try_recv() {
2756                                Ok(event) => {
2757                                    Self::ping_event_drain(&self.peers, &event);
2758                                    self.handle_peer_event(event).await;
2759                                }
2760                                Err(_) => break,
2761                            }
2762                        }
2763                    }
2764                }
2765                // Async piece verification results
2766                Some(result) = self.verify_result_rx.recv() => {
2767                    self.pending_verify.remove(&result.piece);
2768                    // Guard: ignore stale/duplicate results for already-verified pieces
2769                    let dominated = self.chunk_tracker.as_ref()
2770                        .is_some_and(|ct| ct.bitfield().get(result.piece));
2771                    if !dominated {
2772                        if result.passed {
2773                            self.on_piece_verified(result.piece).await;
2774                        } else {
2775                            self.on_piece_hash_failed(result.piece).await;
2776                            // M73: Drivers pick up released pieces automatically via shared state
2777                        }
2778                    }
2779                }
2780                // M96: Hash pool verification results
2781                Some(result) = self.hash_result_rx.recv() => {
2782                    self.handle_hash_result(result).await;
2783                }
2784                // Commands from handle
2785                cmd = self.cmd_rx.recv() => {
2786                    match cmd {
2787                        Some(TorrentCommand::AddPeers { peers, source }) => {
2788                            self.handle_add_peers(peers, source);
2789                        }
2790                        Some(TorrentCommand::Stats { reply }) => {
2791                            let _ = reply.send(self.make_stats());
2792                        }
2793                        Some(TorrentCommand::Pause) => {
2794                            self.handle_pause().await;
2795                        }
2796                        Some(TorrentCommand::Queue) => {
2797                            self.handle_queue();
2798                        }
2799                        Some(TorrentCommand::Resume) => {
2800                            self.handle_resume().await;
2801                        }
2802                        Some(TorrentCommand::ForceResume) => {
2803                            self.user_forced = true;
2804                            self.handle_resume().await;
2805                        }
2806                        Some(TorrentCommand::SetCategory { category, reply }) => {
2807                            // M170: update the per-torrent category label.
2808                            // Marks resume as dirty so the next periodic
2809                            // save captures it.
2810                            self.config.category = category;
2811                            self.need_save_resume = true;
2812                            let _ = reply.send(());
2813                        }
2814                        Some(TorrentCommand::SetTags { tags, reply }) => {
2815                            // M171: replace the per-torrent tag set. Marks
2816                            // resume as dirty so the next periodic save
2817                            // captures it. `make_stats()` reads
2818                            // `self.config.tags` directly so the change is
2819                            // immediately visible to the next `stats()`
2820                            // call.
2821                            self.config.tags = tags;
2822                            self.need_save_resume = true;
2823                            let _ = reply.send(());
2824                        }
2825                        Some(TorrentCommand::GetWebSeeds { reply }) => {
2826                            // M171 Lane B: union of BEP 19 `url-list` and
2827                            // BEP 17 `httpseeds`. Order: BEP 19 first, then
2828                            // BEP 17 — matches the wire order. Returns an
2829                            // empty vec when metadata hasn't resolved yet
2830                            // (magnet still fetching info dict).
2831                            let urls = match &self.meta {
2832                                Some(meta) => {
2833                                    let mut v = Vec::with_capacity(
2834                                        meta.url_list.len() + meta.httpseeds.len(),
2835                                    );
2836                                    v.extend(meta.url_list.iter().cloned());
2837                                    v.extend(meta.httpseeds.iter().cloned());
2838                                    v
2839                                }
2840                                None => Vec::new(),
2841                            };
2842                            let _ = reply.send(urls);
2843                        }
2844                        Some(TorrentCommand::GetPieceStates { reply }) => {
2845                            // M171 Lane B: snapshot per-piece state as qBt
2846                            // codes. Returns an empty vec when metadata
2847                            // hasn't resolved (piece count unknown).
2848                            let states = match self.atomic_states.as_ref() {
2849                                Some(atomic) => atomic.snapshot(),
2850                                None => Vec::new(),
2851                            };
2852                            let _ = reply.send(states);
2853                        }
2854                        Some(TorrentCommand::GetPieceHashes { offset, limit, reply }) => {
2855                            // M171 Lane B: v1 piece hashes live in
2856                            // `meta.info.pieces` (20-byte SHA-1 concat);
2857                            // v2-only piece hashes live in
2858                            // `meta_v2.piece_layers` values (32-byte
2859                            // SHA-256 concat). Hybrid prefers v1 because
2860                            // the qBt client ecosystem treats v1 as the
2861                            // canonical hash surface.
2862                            //
2863                            // M245 L3: select the RAW bytes for the requested
2864                            // window ONLY (cheap chunk-slice clones of resident
2865                            // metadata) and hand them back unencoded. The old
2866                            // path hex-encoded EVERY hash on this recv loop and
2867                            // then threw all but the window away; the
2868                            // `hex::encode` now happens in the handle method off
2869                            // the loop. `skip(offset).take(limit)` reproduces the
2870                            // old `[offset, offset+limit)`-clamped-to-len window.
2871                            let offset = offset as usize;
2872                            let limit = limit as usize;
2873                            let raw: Vec<Vec<u8>> = match self.version {
2874                                irontide_core::TorrentVersion::V1Only
2875                                | irontide_core::TorrentVersion::Hybrid => self
2876                                    .meta
2877                                    .as_ref()
2878                                    .map(|meta| {
2879                                        meta.info
2880                                            .pieces
2881                                            .chunks_exact(20)
2882                                            .skip(offset)
2883                                            .take(limit)
2884                                            .map(<[u8]>::to_vec)
2885                                            .collect::<Vec<Vec<u8>>>()
2886                                    })
2887                                    .unwrap_or_default(),
2888                                irontide_core::TorrentVersion::V2Only => self
2889                                    .meta_v2
2890                                    .as_ref()
2891                                    .map(|m| {
2892                                        m.piece_layers
2893                                            .values()
2894                                            .flat_map(|v| v.chunks_exact(32))
2895                                            .skip(offset)
2896                                            .take(limit)
2897                                            .map(<[u8]>::to_vec)
2898                                            .collect::<Vec<Vec<u8>>>()
2899                                    })
2900                                    .unwrap_or_default(),
2901                            };
2902                            let _ = reply.send(raw);
2903                        }
2904                        Some(TorrentCommand::SaveResumeData { reply }) => {
2905                            let result = self.build_resume_data();
2906                            let _ = reply.send(result);
2907                        }
2908                        Some(TorrentCommand::TakeResumeIfDirty { reply }) => {
2909                            // M245 F1 — atomic take. ATOMICITY GUARD: there must
2910                            // be NO `.await` between reading `need_save_resume`
2911                            // and clearing it. The actor processes commands
2912                            // serially, so this whole arm is one indivisible
2913                            // turn — `build_resume_data()` is synchronous (no
2914                            // yield). Any `need_save_resume = true` set by
2915                            // another command lands strictly BEFORE this turn
2916                            // (captured here) or strictly AFTER (preserved for
2917                            // the next cycle); it can never be lost to a clear
2918                            // that races the build, the pre-M245 stats→save→
2919                            // clear three-step bug. The flag is cleared ONLY on
2920                            // a successful build, so a build error keeps the
2921                            // torrent dirty for retry.
2922                            let result = if self.need_save_resume {
2923                                let built = self.build_resume_data();
2924                                if built.is_ok() {
2925                                    self.need_save_resume = false;
2926                                }
2927                                built.map(Some)
2928                            } else {
2929                                Ok(None)
2930                            };
2931                            let _ = reply.send(result);
2932                        }
2933                        Some(TorrentCommand::SetFilePriority { index, priority, reply }) => {
2934                            // M246: range-scope the synchronous correctness-bearing passes
2935                            // (wanted_pieces / atomic_states / piece_tracker — they gate
2936                            // dispatch and must be coherent the instant the reply is sent)
2937                            // to the changed file's pieces, and DEFER the advisory global
2938                            // order-map rebuild to the coalescing 1 s pipeline tick by
2939                            // setting `order_map_dirty`, instead of running four
2940                            // O(num_pieces) passes on the recv loop. A GUI batch of M files
2941                            // (M back-to-back commands) now sets the flag M times but
2942                            // triggers a single rebuild.
2943                            match self.apply_file_priority_scoped(index, priority) {
2944                                Ok((first, last)) => {
2945                                    self.sync_piece_states_for_range(first, last);
2946                                    if let Some(ref mut pt) = self.piece_tracker {
2947                                        for piece in first..=last {
2948                                            if self.wanted_pieces.get(piece) {
2949                                                pt.mark_wanted(piece);
2950                                            } else {
2951                                                pt.mark_unwanted(piece);
2952                                            }
2953                                        }
2954                                    }
2955                                    self.order_map_dirty = true;
2956                                    let _ = reply.send(Ok(()));
2957                                }
2958                                Err(e) => {
2959                                    let _ = reply.send(Err(e));
2960                                }
2961                            }
2962                        }
2963                        Some(TorrentCommand::FilePriorities { reply }) => {
2964                            let _ = reply.send(self.file_priorities.clone());
2965                        }
2966                        Some(TorrentCommand::ForceReannounce) => {
2967                            self.tracker_manager.force_reannounce();
2968                        }
2969                        Some(TorrentCommand::TrackerList { reply }) => {
2970                            let _ = reply.send(self.tracker_manager.tracker_list());
2971                        }
2972                        Some(TorrentCommand::Scrape { reply }) => {
2973                            let result = self.tracker_manager.scrape().await;
2974                            if let Some((ref url, ref info)) = result {
2975                                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::ScrapeReply {
2976                                    info_hash: self.info_hash,
2977                                    url: url.clone(),
2978                                    complete: info.complete,
2979                                    incomplete: info.incomplete,
2980                                    downloaded: info.downloaded,
2981                                });
2982                            }
2983                            let _ = reply.send(result);
2984                        }
2985                        Some(TorrentCommand::OpenFile { file_index, reply }) => {
2986                            let result = self.handle_open_file(file_index);
2987                            let _ = reply.send(result);
2988                        }
2989                        Some(TorrentCommand::IncomingPeer { stream, addr }) => {
2990                            self.spawn_peer_from_stream_with_mode(
2991                                addr,
2992                                stream,
2993                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2994                            );
2995                        }
2996                        Some(TorrentCommand::UpdateExternalIp { ip }) => {
2997                            self.external_ip = Some(ip);
2998                            post_alert(
2999                                &self.alert_tx,
3000                                &self.alert_mask,
3001                                AlertKind::ExternalIpDetected { ip },
3002                            );
3003                        }
3004                        Some(TorrentCommand::MoveStorage { new_path, reply }) => {
3005                            let result = self.handle_move_storage(new_path).await;
3006                            let _ = reply.send(result);
3007                        }
3008                        Some(TorrentCommand::SpawnSslPeer { addr, stream }) => {
3009                            // TLS is already completed; encryption is handled by TLS layer
3010                            self.spawn_peer_from_stream_with_mode(
3011                                addr,
3012                                stream.0,
3013                                Some(irontide_wire::mse::EncryptionMode::Disabled),
3014                            );
3015                        }
3016                        Some(TorrentCommand::SetDownloadLimit { bytes_per_sec, reply }) => {
3017                            self.download_bucket.lock().set_rate(bytes_per_sec);
3018                            let _ = reply.send(());
3019                        }
3020                        Some(TorrentCommand::SetUploadLimit { bytes_per_sec, reply }) => {
3021                            self.upload_bucket.set_rate(bytes_per_sec);
3022                            let _ = reply.send(());
3023                        }
3024                        Some(TorrentCommand::DownloadLimit { reply }) => {
3025                            let _ = reply.send(self.download_bucket.lock().rate());
3026                        }
3027                        Some(TorrentCommand::UploadLimit { reply }) => {
3028                            let _ = reply.send(self.upload_bucket.rate());
3029                        }
3030                        Some(TorrentCommand::SetSequentialDownload { enabled, reply }) => {
3031                            self.config.sequential_download = enabled;
3032                            let _ = reply.send(());
3033                        }
3034                        Some(TorrentCommand::IsSequentialDownload { reply }) => {
3035                            let _ = reply.send(self.config.sequential_download);
3036                        }
3037                        Some(TorrentCommand::SetSuperSeeding { enabled, reply }) => {
3038                            self.config.super_seeding = enabled;
3039                            self.super_seed = if enabled {
3040                                Some(crate::super_seed::SuperSeedState::new())
3041                            } else {
3042                                None
3043                            };
3044                            let _ = reply.send(());
3045                        }
3046                        Some(TorrentCommand::IsSuperSeeding { reply }) => {
3047                            let _ = reply.send(self.config.super_seeding);
3048                        }
3049                        Some(TorrentCommand::SetSeedMode { enabled, reply }) => {
3050                            self.handle_set_seed_mode(enabled);
3051                            let _ = reply.send(());
3052                        }
3053                        Some(TorrentCommand::SetSeedRatioLimit { limit, reply }) => {
3054                            self.config.seed_ratio_limit = limit;
3055                            self.need_save_resume = true;
3056                            let _ = reply.send(());
3057                        }
3058                        Some(TorrentCommand::AddTracker { url }) => {
3059                            self.tracker_manager.add_tracker_url(&url);
3060                        }
3061                        Some(TorrentCommand::ReplaceTrackers { urls, reply }) => {
3062                            self.tracker_manager.replace_all(&urls);
3063                            let _ = reply.send(());
3064                        }
3065                        Some(TorrentCommand::ForceRecheck { reply }) => {
3066                            self.handle_force_recheck(reply).await;
3067                        }
3068                        Some(TorrentCommand::RenameFile { file_index, new_name, reply }) => {
3069                            let result = self.handle_rename_file(file_index, new_name).await;
3070                            let _ = reply.send(result);
3071                        }
3072                        Some(TorrentCommand::SetMaxConnections { limit, reply }) => {
3073                            self.max_connections = limit;
3074                            let _ = reply.send(());
3075                        }
3076                        Some(TorrentCommand::MaxConnections { reply }) => {
3077                            let _ = reply.send(self.max_connections);
3078                        }
3079                        Some(TorrentCommand::SetMaxUploads { limit, reply }) => {
3080                            self.choker.set_unchoke_slots(limit);
3081                            let _ = reply.send(());
3082                        }
3083                        Some(TorrentCommand::MaxUploads { reply }) => {
3084                            let _ = reply.send(self.choker.unchoke_slots());
3085                        }
3086                        Some(TorrentCommand::GetPeerInfo { reply }) => {
3087                            let _ = reply.send(self.build_peer_info());
3088                        }
3089                        Some(TorrentCommand::GetDownloadQueue { reply }) => {
3090                            let _ = reply.send(self.build_download_queue());
3091                        }
3092                        Some(TorrentCommand::HavePiece { index, reply }) => {
3093                            let has = self.chunk_tracker.as_ref()
3094                                .is_some_and(|ct| ct.has_piece(index));
3095                            let _ = reply.send(has);
3096                        }
3097                        Some(TorrentCommand::PieceAvailability { reply }) => {
3098                            // M246 (D5): this O(num_peers * num_pieces) scan is the
3099                            // only super-linear residual handler, but it is a RARE
3100                            // on-demand read query — its sole caller is the public
3101                            // `piece_availability()` accessor, driven by the client's
3102                            // GUI/WebUI piece-availability bar, never per-tick or
3103                            // per-message. Incremental maintenance (hooking every
3104                            // connect / disconnect / HAVE to keep a running counter)
3105                            // is deliberately out of the L4 recv-loop-hardening scope:
3106                            // it would add steady-state cost to the hot path to speed
3107                            // up a query a human triggers a few times a minute.
3108                            let mut avail = vec![0u32; self.num_pieces as usize];
3109                            for peer in self.peers.values() {
3110                                for i in 0..self.num_pieces {
3111                                    if peer.bitfield.get(i) {
3112                                        avail[i as usize] += 1;
3113                                    }
3114                                }
3115                            }
3116                            let _ = reply.send(avail);
3117                        }
3118                        Some(TorrentCommand::FileProgress { reply }) => {
3119                            let _ = reply.send(self.compute_file_progress());
3120                        }
3121                        Some(TorrentCommand::InfoHashes { reply }) => {
3122                            let _ = reply.send(self.info_hashes.clone());
3123                        }
3124                        Some(TorrentCommand::TorrentFile { reply }) => {
3125                            let _ = reply.send(self.meta.clone());
3126                        }
3127                        Some(TorrentCommand::TorrentFileV2 { reply }) => {
3128                            let _ = reply.send(self.meta_v2.clone());
3129                        }
3130                        Some(TorrentCommand::ForceDhtAnnounce) => {
3131                            self.handle_force_dht_announce().await;
3132                        }
3133                        Some(TorrentCommand::ReadPiece { index, reply }) => {
3134                            let result = self.handle_read_piece(index).await;
3135                            let _ = reply.send(result);
3136                        }
3137                        Some(TorrentCommand::FlushCache { reply }) => {
3138                            let result = self.handle_flush_cache().await;
3139                            let _ = reply.send(result);
3140                        }
3141                        Some(TorrentCommand::ClearError) => {
3142                            self.handle_clear_error().await;
3143                        }
3144                        Some(TorrentCommand::ClearSaveResumeFlag) => {
3145                            self.need_save_resume = false;
3146                        }
3147                        Some(TorrentCommand::MarkResumeDirty) => {
3148                            // M245 F1 — re-arm after a failed resume WRITE so the
3149                            // torrent is retried next save cycle (see
3150                            // `TakeResumeIfDirty`).
3151                            self.need_save_resume = true;
3152                        }
3153                        Some(TorrentCommand::RestoreResumeBitmap { pieces, reply }) => {
3154                            let result = self.handle_restore_resume_bitmap(pieces);
3155                            let _ = reply.send(result);
3156                        }
3157                        Some(TorrentCommand::RestoreWebSeedStats { stats, reply }) => {
3158                            self.web_seed_stats = stats;
3159                            let _ = reply.send(Ok(()));
3160                        }
3161                        Some(TorrentCommand::GetPeerSourceCounts { reply }) => {
3162                            let _ = reply.send((self.pex_peer_count, self.lsd_peer_count));
3163                        }
3164                        Some(TorrentCommand::QueryUnchokeDurations { reply }) => {
3165                            let mut out = self.unchoke_durations.clone();
3166                            // Merge in each currently-live peer's transient
3167                            // accumulator + any in-flight unchoke window.
3168                            let now = Instant::now();
3169                            for peer in self.peers.values() {
3170                                let mut delta = peer.unchoke_duration_total;
3171                                if let Some(start) = peer.am_unchoke_started_at {
3172                                    delta += now.duration_since(start);
3173                                }
3174                                if !delta.is_zero() {
3175                                    *out.entry(peer.addr).or_default() += delta;
3176                                }
3177                            }
3178                            let _ = reply.send(out);
3179                        }
3180                        Some(TorrentCommand::GetWebSeedStats { reply }) => {
3181                            let snapshot: Vec<_> = self.web_seed_stats.values().cloned().collect();
3182                            let _ = reply.send(snapshot);
3183                        }
3184                        Some(TorrentCommand::FileStatus { reply }) => {
3185                            let _ = reply.send(self.build_file_status());
3186                        }
3187                        Some(TorrentCommand::Flags { reply }) => {
3188                            let _ = reply.send(self.build_flags());
3189                        }
3190                        Some(TorrentCommand::SetFlags { flags, reply }) => {
3191                            self.apply_set_flags(flags).await;
3192                            let _ = reply.send(());
3193                        }
3194                        Some(TorrentCommand::UnsetFlags { flags, reply }) => {
3195                            self.apply_unset_flags(flags).await;
3196                            let _ = reply.send(());
3197                        }
3198                        Some(TorrentCommand::ConnectPeer { addr }) => {
3199                            self.handle_connect_peer(addr);
3200                        }
3201                        Some(TorrentCommand::PreResolvedMetadata { info_bytes, peers }) => {
3202                            self.handle_pre_resolved_metadata(info_bytes, peers).await;
3203                        }
3204                        #[cfg(feature = "test-util")]
3205                        Some(TorrentCommand::TestInjectMetadata { info_bytes, reply }) => {
3206                            // Reuses the existing handle_pre_resolved_metadata at torrent.rs:3665.
3207                            // Synchronous because the test caller depends on completion before
3208                            // proceeding (unlike the production resolver which is fire-and-forget).
3209                            self.handle_pre_resolved_metadata(info_bytes, vec![]).await;
3210                            let _ = reply.send(());
3211                        }
3212                        Some(TorrentCommand::GetMeta { reply }) => {
3213                            // v0.173.1: single source of truth for torrent
3214                            // metadata — replaces `SessionActor.TorrentEntry.meta`
3215                            // so magnet-added torrents no longer lose the info
3216                            // dict between the TorrentActor and the session.
3217                            let _ = reply.send(self.meta.clone());
3218                        }
3219                        Some(TorrentCommand::UpdateSettings(delta)) => {
3220                            self.handle_update_settings(&delta);
3221                        }
3222                        Some(TorrentCommand::Shutdown) => {
3223                            info!("torrent actor: received Shutdown command, exiting");
3224                            self.shutdown_web_seeds().await;
3225                            self.shutdown_peers().await;
3226                            return;
3227                        }
3228                        None => {
3229                            warn!("torrent actor: cmd_rx channel closed (all senders dropped), exiting");
3230                            self.shutdown_web_seeds().await;
3231                            self.shutdown_peers().await;
3232                            return;
3233                        }
3234                    }
3235                }
3236                // Async disk write errors
3237                Some(err) = self.write_error_rx.recv() => {
3238                    warn!(piece = err.piece, begin = err.begin, "async disk write failed: {}", err.error);
3239                }
3240                // Accept incoming peers
3241                result = accept_incoming(&mut self.listener) => {
3242                    if let Ok((stream, addr)) = result {
3243                        self.spawn_peer_from_stream(addr, stream);
3244                    }
3245                }
3246                // Accept incoming I2P peers (M41)
3247                stream = accept_i2p(&mut self.i2p_accept_rx) => {
3248                    if let Some(stream) = stream {
3249                        self.handle_i2p_incoming(stream);
3250                    }
3251                }
3252                // Rate update timer (2s) — decoupled from the 10s unchoke
3253                // interval so the GUI sees responsive DL/UL numbers.
3254                _ = rate_interval.tick() => {
3255                    self.update_peer_rates();
3256                }
3257                // Unchoke timer
3258                _ = unchoke_interval.tick() => {
3259                    // M144 deviation from BEP 3 §3 (10s choking algorithm):
3260                    // We skip the choker during download — intentional rqbit-parity
3261                    // decision. BEP 3 specifies a 10s choking interval to manage
3262                    // upload slots via tit-for-tat. But IronTide unchokes all peers
3263                    // unconditionally on connect (M107); running the choker every
3264                    // 10s re-chokes most peers, breaking the reciprocity that drove
3265                    // the original unchoke. Only run during seeding/sharing where
3266                    // upload-slot management matters. Reviewed M174.
3267                    if self.state == TorrentState::Seeding
3268                        || self.state == TorrentState::Sharing
3269                    {
3270                        self.slot_tuner.observe(self.upload_bytes_interval);
3271                        self.choker.observe_throughput(self.upload_bytes_interval);
3272                        self.upload_bytes_interval = 0;
3273                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3274                        self.run_choker().await;
3275                        // v0.187.3: a normal tick satisfies the immediate-tick
3276                        // request, so clear the flag.
3277                        self.force_immediate_choker_tick = false;
3278                    } else {
3279                        self.upload_bytes_interval = 0;
3280                    }
3281                    // Update streaming cursors and piece priorities
3282                    self.update_streaming_cursors();
3283                    // Update auto-sequential hysteresis (M45)
3284                    if self.config.auto_sequential {
3285                        self.auto_sequential_active = crate::piece_selector::evaluate_auto_sequential(
3286                            self.piece_owner.iter().filter(|o| o.is_some()).count(),
3287                            self.peers.len(),
3288                            self.auto_sequential_active,
3289                        );
3290                    }
3291                    // Periodic web seed piece reassignment (moved from dht_recheck timer)
3292                    self.assign_pieces_to_web_seeds();
3293                }
3294                // Optimistic unchoke timer
3295                _ = optimistic_interval.tick() => {
3296                    self.rotate_optimistic();
3297                }
3298                // M107: Receive connect requests from the peer adder task
3299                Some(connect_peer) = async {
3300                    match self.connect_rx.as_mut() {
3301                        Some(rx) => rx.recv().await,
3302                        None => std::future::pending().await,
3303                    }
3304                } => {
3305                    self.handle_adder_connect(connect_peer);
3306                }
3307                () = &mut dht_requery_sleep, if self.state != TorrentState::Complete
3308                    && self.state != TorrentState::Paused
3309                    && self.state != TorrentState::Queued
3310                    && self.state != TorrentState::Seeding
3311                    && self.state != TorrentState::Stopped => {
3312                    self.run_dht_requery().await;
3313                    dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::from_mins(1)));
3314                }
3315                // M143: Tracker re-announce timer — starts a background announce,
3316                // never blocks. Only fires when no announce is in-flight.
3317                // Also fires during FetchingMetadata so magnets with &tr= URLs
3318                // can discover peers before metadata arrives.
3319                () = async {
3320                    match self.tracker_manager.next_announce_in() {
3321                        Some(dur) => tokio::time::sleep(dur).await,
3322                        None => std::future::pending().await,
3323                    }
3324                }, if self.tracker_result_rx.is_none() => {
3325                    let left = self.calculate_left();
3326                    self.tracker_result_rx = Some(self.tracker_manager.start_announce(
3327                        irontide_tracker::AnnounceEvent::None,
3328                        self.uploaded,
3329                        self.downloaded,
3330                        left,
3331                    ));
3332                }
3333                // M143: Streaming tracker results — process each tracker
3334                // response as it arrives, without blocking the actor loop.
3335                result = async {
3336                    match self.tracker_result_rx.as_mut() {
3337                        Some(rx) => rx.recv().await,
3338                        None => std::future::pending().await,
3339                    }
3340                } => {
3341                    match result {
3342                        Some(batch) => {
3343                            let (peers, outcome) = self.tracker_manager.process_tracker_result(batch);
3344                            self.fire_tracker_alerts(&[outcome]);
3345                            if !peers.is_empty() {
3346                                debug!(count = peers.len(), "tracker returned peers (streaming)");
3347                                self.handle_add_peers(peers, PeerSource::Tracker);
3348                            }
3349                        }
3350                        None => {
3351                            // All trackers responded — clear in-flight state so
3352                            // the timer arm can re-fire for the next announce cycle.
3353                            self.tracker_result_rx = None;
3354                        }
3355                    }
3356                }
3357                // DHT v4 peer discovery
3358                result = async {
3359                    match &mut self.dht_peers_rx {
3360                        Some(rx) => rx.recv().await,
3361                        None => std::future::pending().await,
3362                    }
3363                } => {
3364                    if let Some(peers) = result {
3365                        debug!(count = peers.len(), "DHT v4 returned peers");
3366                        self.handle_add_peers(peers, PeerSource::Dht);
3367                    } else {
3368                        debug!("DHT v4 peer search exhausted");
3369                        self.dht_peers_rx = None;
3370                    }
3371                }
3372                // DHT v6 peer discovery
3373                result = async {
3374                    match &mut self.dht_v6_peers_rx {
3375                        Some(rx) => rx.recv().await,
3376                        None => std::future::pending().await,
3377                    }
3378                } => {
3379                    if let Some(peers) = result {
3380                        debug!(count = peers.len(), "DHT v6 returned peers");
3381                        self.dht_v6_empty_count = 0; // V6 is working, reset
3382                        self.handle_add_peers(peers, PeerSource::Dht);
3383                    } else {
3384                        self.dht_v6_peers_rx = None;
3385                        self.dht_v6_empty_count += 1;
3386                        if self.dht_v6_empty_count == 30 {
3387                            debug!("DHT v6 routing table persistently empty, giving up");
3388                        } else if self.dht_v6_empty_count < 30 {
3389                            debug!("DHT v6 peer search exhausted");
3390                        }
3391                    }
3392                }
3393                // Dual-swarm: DHT v4 v2-hash peer discovery (hybrid)
3394                result = async {
3395                    match &mut self.dht_v2_peers_rx {
3396                        Some(rx) => rx.recv().await,
3397                        None => std::future::pending().await,
3398                    }
3399                } => {
3400                    if let Some(peers) = result {
3401                        debug!(count = peers.len(), "DHT v4 v2-swarm returned peers");
3402                        self.handle_add_peers(peers, PeerSource::Dht);
3403                    } else {
3404                        debug!("DHT v4 v2-swarm peer search exhausted");
3405                        self.dht_v2_peers_rx = None;
3406                    }
3407                }
3408                // Dual-swarm: DHT v6 v2-hash peer discovery (hybrid)
3409                result = async {
3410                    match &mut self.dht_v6_v2_peers_rx {
3411                        Some(rx) => rx.recv().await,
3412                        None => std::future::pending().await,
3413                    }
3414                } => {
3415                    if let Some(peers) = result {
3416                        debug!(count = peers.len(), "DHT v6 v2-swarm returned peers");
3417                        self.handle_add_peers(peers, PeerSource::Dht);
3418                    } else {
3419                        debug!("DHT v6 v2-swarm peer search exhausted");
3420                        self.dht_v6_v2_peers_rx = None;
3421                    }
3422                }
3423                // M44: Suggest cached pieces timer
3424                _ = async {
3425                    match suggest_interval {
3426                        Some(ref mut interval) => interval.tick().await,
3427                        None => std::future::pending().await,
3428                    }
3429                } => {
3430                    self.suggest_cached_pieces().await;
3431                }
3432                _ = turnover_interval.tick() => {
3433                    self.run_steal_queue_maintenance();
3434                }
3435                // Pipeline tick (1s) — update EWMA, snub detection, peer scoring
3436                _ = pipeline_tick_interval.tick() => {
3437                    let snub_timeout = Duration::from_secs(u64::from(self.config.snub_timeout_secs));
3438
3439                    for (_addr, peer) in &mut self.peers {
3440                        peer.pipeline.tick();
3441
3442                        // Snub detection: no data for snub_timeout_secs while unchoked
3443                        if !peer.peer_choking && !peer.snubbed {
3444                            let idle = peer.last_data_received
3445                                .is_some_and(|t| t.elapsed() > snub_timeout);
3446                            if idle {
3447                                peer.snubbed = true;
3448                                // M106: Count pending requests as timed-out blocks
3449                                peer.blocks_timed_out = peer.blocks_timed_out
3450                                    .saturating_add(peer.pending_requests.len() as u64);
3451                                debug!(%_addr, "peer snubbed (no data for {}s)", self.config.snub_timeout_secs);
3452                            }
3453                        }
3454                    }
3455
3456                    // Refresh cached peer rates for steal decisions (avoids
3457                    // rebuilding a FxHashMap from all peers on every block arrival).
3458                    self.refresh_peer_rates();
3459
3460                    // M73: Periodic endgame activation check (was in batch_fill_all_peers)
3461                    if !self.end_game.is_active() {
3462                        self.check_end_game_activation();
3463                    }
3464
3465                    self.tick_dispatch_safety_wake();
3466
3467                    // M138: Proactive choke rotation — every tick, evict up to N choked peers
3468                    if self.config.choke_rotation_max_evictions > 0
3469                        && self.state == TorrentState::Downloading
3470                    {
3471                        self.run_choke_rotation();
3472                    }
3473
3474                    // M246: coalesced order-map rebuild. The SetFilePriority arm sets
3475                    // `order_map_dirty` instead of building inline; a batch of priority
3476                    // changes collapses to ONE rebuild here (at most ~1 s later — the
3477                    // order map is an advisory dispatch walk-order hint, and the
3478                    // piece_tracker already gates wanted/reserved synchronously).
3479                    if self.order_map_dirty {
3480                        self.rebuild_order_map_now();
3481                    }
3482                }
3483                // (M75: peer tasks handle dispatch via integrated select! arm)
3484                // End-game refill tick (200ms) — replace reactive per-block cascade
3485                // with periodic batch refill. All peers with available pipeline slots
3486                // get new end-game blocks, preventing idle stalls between ticks.
3487                _ = end_game_tick_interval.tick(), if self.end_game.is_active() => {
3488                    let addrs: Vec<SocketAddr> = self.peers.iter()
3489                        .filter(|(_, p)| !p.peer_choking && p.pending_requests.len() < END_GAME_DEPTH)
3490                        .map(|(addr, _)| *addr)
3491                        .collect();
3492                    for addr in addrs {
3493                        self.request_end_game_block(addr).await;
3494                    }
3495                }
3496                // M107: Metadata piece timeout — re-request timed-out pieces from
3497                // all non-rejected peers that support ut_metadata.
3498                _ = metadata_timeout_interval.tick(), if self.state == TorrentState::FetchingMetadata => {
3499                    // Collect timed-out pieces (immutable borrow, then release).
3500                    let timed_out: Vec<u32> = self
3501                        .metadata_downloader
3502                        .as_ref()
3503                        .map(MetadataDownloader::timed_out_pieces)
3504                        .unwrap_or_default();
3505
3506                    if !timed_out.is_empty() {
3507                        debug!(count = timed_out.len(), "metadata pieces timed out, re-requesting");
3508
3509                        // Collect eligible peers (non-rejected, support ut_metadata).
3510                        // Clone cmd_tx to avoid holding borrows across the send loop.
3511                        let eligible_senders: Vec<mpsc::Sender<PeerCommand>> = self
3512                            .peers
3513                            .iter()
3514                            .filter(|(addr, peer)| {
3515                                self.metadata_downloader
3516                                    .as_ref()
3517                                    .is_some_and(|dl| !dl.is_rejected(addr))
3518                                    && peer
3519                                        .ext_handshake
3520                                        .as_ref()
3521                                        .is_some_and(|h| h.metadata_size.is_some())
3522                            })
3523                            .map(|(_, peer)| peer.cmd_tx.clone())
3524                            .collect();
3525
3526                        // Send requests (uses cloned senders, no borrow conflict).
3527                        for cmd_tx in &eligible_senders {
3528                            for &piece in &timed_out {
3529                                let _ = cmd_tx.try_send(PeerCommand::RequestMetadata { piece });
3530                            }
3531                        }
3532
3533                        // Update request times in the downloader.
3534                        if let Some(ref mut dl) = self.metadata_downloader {
3535                            for piece in timed_out {
3536                                dl.reset_request_time(piece);
3537                            }
3538                        }
3539                    }
3540                }
3541                // Periodic download status report (5s)
3542                _ = diag_interval.tick() => {
3543                    // Heartbeat: log state regardless of download state
3544                    {
3545                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3546                        let eg = self.end_game.is_active();
3547                        let eg_blocks = self.end_game.block_count();
3548                        info!(state = ?self.state, have, total = self.num_pieces, end_game = eg, eg_blocks, "heartbeat");
3549                    }
3550                    if self.state == TorrentState::Downloading {
3551                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3552                        let in_flight = self.atomic_states.as_ref().map_or(0, |s| s.in_flight_count() as usize);
3553                        let unchoked = self.peers.values().filter(|p| !p.peer_choking).count();
3554                        info!(have, in_flight, total = self.num_pieces,
3555                              downloaded_mb = self.downloaded / (1024 * 1024),
3556                              peers = self.peers.len(), unchoked,
3557                              "download progress");
3558                        for (addr, p) in &self.peers {
3559                            let last_data = p.last_data_received.map_or(9999, |t| t.elapsed().as_secs());
3560                            trace!(%addr,
3561                                   choking = p.peer_choking,
3562                                   pending = p.pending_requests.len(),
3563                                   ewma_rate = p.pipeline.ewma_rate() as u64,
3564                                   last_data_secs = last_data,
3565                                   bf_ones = p.bitfield.count_ones(),
3566                                   "peer state");
3567                        }
3568                    }
3569                }
3570                // M108: 30s connection success rate summary for variance diagnosis
3571                _ = conn_stats_interval.tick() => {
3572                    if self.connect_attempts > 0 {
3573                        let succeeded = self.connect_attempts.saturating_sub(self.connect_failures);
3574                        let success_pct = (succeeded as f64 / self.connect_attempts as f64 * 100.0) as u32;
3575                        info!(
3576                            connected = self.peers.len(),
3577                            attempted = self.connect_attempts,
3578                            failed = self.connect_failures,
3579                            success_rate = %format!("{success_pct}%"),
3580                            "connection stats"
3581                        );
3582                    }
3583                }
3584                // M147: Soft reap — disconnect connecting peers without TCP SYN-ACK.
3585                // v0.173.3: uses the buffer-fill variant + index iteration to reuse
3586                // soft_reap_buf across ticks without ever moving its heap allocation.
3587                // SocketAddr is Copy, so indexing into self.soft_reap_buf yields a
3588                // value copy and does not borrow self.soft_reap_buf for the loop body.
3589                _ = soft_reap_interval.tick() => {
3590                    let soft_timeout = self.config.connect_soft_timeout;
3591                    if soft_timeout > 0 {
3592                        if let Some(ref ps) = self.peer_states {
3593                            ps.soft_reap_candidates_into(
3594                                Duration::from_secs(soft_timeout),
3595                                &mut self.soft_reap_buf,
3596                            );
3597                        } else {
3598                            self.soft_reap_buf.clear();
3599                        }
3600                        for i in 0..self.soft_reap_buf.len() {
3601                            let peer_addr = self.soft_reap_buf[i];
3602                            debug!(%peer_addr, soft_timeout, "soft reap: no TCP SYN-ACK");
3603                            // Remove from connect_permits so RAII drops the permit
3604                            self.connect_permits.remove(&peer_addr);
3605                            self.disconnect_peer(peer_addr, "soft reap: no TCP SYN-ACK");
3606                            if let Some(ref ps) = self.peer_states
3607                                && let Some(backoff) = ps.mark_dead(peer_addr)
3608                            {
3609                                let ps_clone = Arc::clone(ps);
3610                                tokio::spawn(async move {
3611                                    tokio::time::sleep(backoff).await;
3612                                    ps_clone.mark_queued_for_retry(peer_addr);
3613                                });
3614                            }
3615                        }
3616                        self.soft_reap_buf.clear();
3617                    }
3618                }
3619                // M148 + v0.187.3: Proactive eviction with churn guard.
3620                //
3621                //                  PROACTIVE EVICTION POLICY (M148 → v0.187.3)
3622                //                  ============================================
3623                //
3624                //   Tick every 2s
3625                //   ├─ state == Seeding?                          ──── no-op
3626                //   ├─ live < (effective_max * 0.95)?             ──── no-op
3627                //   └─ eviction_history (in last 60s) < limit?    ──── no-op
3628                //       │
3629                //       ▼
3630                //   for up to 5 candidates:
3631                //     find_eviction_candidate() →
3632                //       Pass 0  ZeroThroughput     [skipped if state==Seeding]
3633                //               [skipped if peer.live_since < pass0_grace_secs]
3634                //               ban 10min, push to banned_set (FIFO cap 1024)
3635                //       Pass 1  Choked > 10s       no ban
3636                //       Pass 2  LowThroughput      no ban
3637                //       Pass 3  Bep40 priority     [only on HandshakeComplete]
3638                //               no ban
3639                //
3640                //     on evict: eviction_history.push_back(now)
3641                _ = eviction_interval.tick() => {
3642                    // v0.187.3 / Bug 8a: opportunistically service a pending
3643                    // immediate-tick request from a recent state transition
3644                    // (typically Downloading → Seeding). Caps the worst-case
3645                    // first-unchoke latency at the 2s eviction interval
3646                    // instead of the 10s unchoke interval.
3647                    if self.force_immediate_choker_tick
3648                        && (self.state == TorrentState::Seeding
3649                            || self.state == TorrentState::Sharing)
3650                    {
3651                        self.slot_tuner.observe(self.upload_bytes_interval);
3652                        self.choker.observe_throughput(self.upload_bytes_interval);
3653                        self.upload_bytes_interval = 0;
3654                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3655                        self.run_choker().await;
3656                        self.force_immediate_choker_tick = false;
3657                    }
3658                    if self.state != TorrentState::Seeding {
3659                        // v0.187.3 / 3A: prune eviction_history of entries older
3660                        // than 60s, then gate on the configured limit.
3661                        let prune_cutoff = std::time::Duration::from_mins(1);
3662                        while self
3663                            .eviction_history
3664                            .front()
3665                            .copied()
3666                            .is_some_and(|t| t.elapsed() > prune_cutoff)
3667                        {
3668                            self.eviction_history.pop_front();
3669                        }
3670                        let limit = self.config.proactive_evictions_per_minute_limit as usize;
3671                        let window_ok = self.eviction_history.len() < limit;
3672
3673                        // v0.187.3 / Pressure gate: 0.95 (was 0.75 in v0.187.2).
3674                        // Higher threshold gives slow-start peers room to ramp
3675                        // before the eviction loop fires.
3676                        let should_evict = window_ok
3677                            && self.peer_states.as_ref().is_some_and(|ps| {
3678                                let live = ps
3679                                    .stats
3680                                    .live
3681                                    .load(std::sync::atomic::Ordering::Relaxed);
3682                                #[allow(
3683                                    clippy::cast_possible_truncation,
3684                                    clippy::cast_sign_loss
3685                                )]
3686                                let threshold =
3687                                    (self.effective_max_connections() as f32 * 0.95) as u32;
3688                                debug_assert!(
3689                                    self.effective_max_connections()
3690                                        <= crate::torrent_peers::HARD_PEER_CEILING,
3691                                    "effective_max must be clamped to HARD_PEER_CEILING"
3692                                );
3693                                live >= threshold
3694                            });
3695                        if should_evict {
3696                            // Evict up to 5 deadweight per tick, but no more than
3697                            // (limit - history.len()) total per the sliding window.
3698                            let max_this_tick = 5.min(limit.saturating_sub(self.eviction_history.len()));
3699                            for _ in 0..max_this_tick {
3700                                match self.find_eviction_candidate() {
3701                                    Some((victim, pass)) => {
3702                                        debug!(%victim, ?pass, "v0.187.3 proactive eviction");
3703                                        self.disconnect_peer(victim, "proactive eviction");
3704                                        if matches!(pass, crate::torrent_peers::EvictionPass::ZeroThroughput)
3705                                            && let Some(ref ps) = self.peer_states
3706                                        {
3707                                            ps.add_eviction_ban(victim);
3708                                        }
3709                                        self.eviction_history.push_back(std::time::Instant::now());
3710                                    }
3711                                    None => break,
3712                                }
3713                            }
3714                        }
3715
3716                        // M149: Piece stealing scan (piggybacks on same 2s interval)
3717                        self.run_piece_steal_scan();
3718                    }
3719                }
3720                // Rate limiter refill (100ms)
3721                _ = refill_interval.tick() => {
3722                    let elapsed = Duration::from_millis(100);
3723                    self.upload_bucket.refill(elapsed);
3724                    self.download_bucket.lock().refill(elapsed);
3725                    // Refill per-class buckets and apply mixed-mode (M45)
3726                    self.rate_limiter_set.refill(elapsed);
3727                    let (tcp_peers, utp_peers) = self.transport_peer_counts();
3728                    self.rate_limiter_set.apply_mixed_mode(
3729                        self.config.mixed_mode_algorithm,
3730                        tcp_peers,
3731                        utp_peers,
3732                        self.config.upload_rate_limit,
3733                    );
3734                }
3735            }
3736
3737            // M112: drain holepunch attempts (bridging sync disconnect_peer → async try_holepunch)
3738            for target in std::mem::take(&mut self.holepunch_pending) {
3739                self.try_holepunch(target).await;
3740            }
3741        }
3742    }
3743
3744    // ----- Command handlers -----
3745
3746    /// Compute distributed copy availability across the swarm.
3747    ///
3748    /// Returns `(full_copies, fraction, copies_float)` where `fraction` is in thousandths.
3749    pub(crate) fn distributed_copies(&self) -> (u32, u32, f32) {
3750        if self.num_pieces == 0 || self.peers.is_empty() {
3751            return (0, 0, 0.0);
3752        }
3753
3754        let num = self.num_pieces as usize;
3755        let mut availability = vec![0u32; num];
3756
3757        for peer in self.peers.values() {
3758            for idx in 0..self.num_pieces {
3759                if peer.bitfield.get(idx) {
3760                    availability[idx as usize] += 1;
3761                }
3762            }
3763        }
3764
3765        let min_avail = availability.iter().copied().min().unwrap_or(0);
3766        let rarest_count = availability.iter().filter(|&&c| c == min_avail).count() as u32;
3767        let fraction = ((self.num_pieces - rarest_count) * 1000) / self.num_pieces;
3768        let copies_float = min_avail as f32 + fraction as f32 / 1000.0;
3769
3770        (min_avail, fraction, copies_float)
3771    }
3772
3773    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3774    /// `GetDownloadQueue` query) — intentionally left on the recv loop; it has
3775    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3776    fn build_download_queue(&self) -> Vec<PartialPieceInfo> {
3777        self.piece_owner
3778            .iter()
3779            .enumerate()
3780            .filter_map(|(piece_index, owner)| {
3781                owner.map(|_| {
3782                    let piece_index = piece_index as u32;
3783                    let blocks_in_piece = self
3784                        .lengths
3785                        .as_ref()
3786                        .map_or(0, |l| l.piece_size(piece_index).div_ceil(l.chunk_size()));
3787                    PartialPieceInfo {
3788                        piece_index,
3789                        blocks_in_piece,
3790                        blocks_assigned: 0,
3791                    }
3792                })
3793            })
3794            .collect()
3795    }
3796
3797    /// Compute per-file downloaded bytes.
3798    ///
3799    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3800    /// `FileProgress` query) — intentionally left on the recv loop; it has
3801    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3802    fn compute_file_progress(&self) -> Vec<u64> {
3803        let Some(meta) = self.meta.as_ref() else {
3804            return Vec::new();
3805        };
3806        let Some(lengths) = self.lengths.as_ref() else {
3807            return Vec::new();
3808        };
3809        let Some(chunk_tracker) = self.chunk_tracker.as_ref() else {
3810            return Vec::new();
3811        };
3812
3813        let files = meta.info.files();
3814        if files.is_empty() {
3815            return Vec::new();
3816        }
3817
3818        let piece_length = lengths.piece_length();
3819        let mut result = Vec::with_capacity(files.len());
3820        let mut file_offset = 0u64;
3821
3822        for file_entry in &files {
3823            let file_len = file_entry.length;
3824            if file_len == 0 {
3825                result.push(0);
3826                file_offset += file_len;
3827                continue;
3828            }
3829
3830            let file_end = file_offset + file_len;
3831            let first_piece = (file_offset / piece_length) as u32;
3832            let last_piece = ((file_end - 1) / piece_length) as u32;
3833
3834            let mut downloaded = 0u64;
3835
3836            for p in first_piece..=last_piece {
3837                if !chunk_tracker.has_piece(p) {
3838                    continue;
3839                }
3840
3841                let piece_start = lengths.piece_offset(p);
3842                let piece_end = piece_start + u64::from(lengths.piece_size(p));
3843
3844                // Clamp to file boundaries
3845                let overlap_start = piece_start.max(file_offset);
3846                let overlap_end = piece_end.min(file_end);
3847
3848                if overlap_start < overlap_end {
3849                    downloaded += overlap_end - overlap_start;
3850                }
3851            }
3852
3853            result.push(downloaded);
3854            file_offset = file_end;
3855        }
3856
3857        result
3858    }
3859
3860    /// Exponential backoff delay for V6 DHT retries (M97).
3861    /// 100ms, 200ms, 400ms, 800ms, 1600ms, 3200ms, 5000ms (cap).
3862    fn v6_retry_delay(&self) -> std::time::Duration {
3863        let base_ms: u64 = 100;
3864        let max_ms: u64 = 5000;
3865        let delay_ms = base_ms
3866            .saturating_mul(
3867                1u64.checked_shl(self.dht_v6_empty_count)
3868                    .unwrap_or(u64::MAX),
3869            )
3870            .min(max_ms);
3871        std::time::Duration::from_millis(delay_ms)
3872    }
3873
3874    /// Check if enough time has elapsed for the next V6 DHT retry (M97).
3875    fn should_retry_v6(&self) -> bool {
3876        let Some(last) = self.dht_v6_last_retry else {
3877            return true; // First attempt
3878        };
3879        last.elapsed() >= self.v6_retry_delay()
3880    }
3881
3882    /// Force an immediate DHT announce on all available DHT handles (v4 + v6).
3883    async fn handle_force_dht_announce(&self) {
3884        if let Some(dht) = self.current_dht()
3885            && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
3886        {
3887            warn!("Force DHT v4 announce failed: {e}");
3888        }
3889        if let Some(dht6) = self.current_dht_v6()
3890            && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
3891        {
3892            debug!("Force DHT v6 announce failed: {e}");
3893        }
3894        // Dual-swarm: also announce v2 hash for hybrid torrents
3895        if self.info_hashes.is_hybrid()
3896            && let Some(v2) = self.info_hashes.v2
3897        {
3898            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
3899            if v2_as_v1 != self.info_hash {
3900                if let Some(dht) = self.current_dht()
3901                    && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
3902                {
3903                    debug!("Force DHT v4 dual-swarm announce failed: {e}");
3904                }
3905                if let Some(dht6) = self.current_dht_v6()
3906                    && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
3907                {
3908                    debug!("Force DHT v6 dual-swarm announce failed: {e}");
3909                }
3910            }
3911        }
3912    }
3913
3914    /// M107: Periodic DHT re-query — discovers new peers during download.
3915    ///
3916    /// Replaces the old fixed 30s `dht_recheck_interval`. Clears the adder's
3917    /// seen set so previously-known peers can be re-evaluated, then issues
3918    /// fresh `get_peers` on all active DHT handles (v4, v6, v2-swarm).
3919    async fn run_dht_requery(&mut self) {
3920        if !self.config.enable_dht {
3921            return;
3922        }
3923
3924        // Guard: don't re-query if we already have plenty of known peers.
3925        // M133: Scale with config instead of hardcoded 500 — with max_peers=128
3926        // this becomes 512, close to the old value but adapts to custom limits.
3927        if self.peers.len() > self.config.max_peers.saturating_mul(4) {
3928            return;
3929        }
3930
3931        // M134: DhtLookup is now persistent — it re-injects routing table roots
3932        // every 15s internally. Only issue a fresh get_peers if the previous
3933        // lookup's channel has closed (lookup exhausted or aborted). Issuing a
3934        // new get_peers while one is active would abort the existing DhtLookup,
3935        // destroying its accumulated 256-node state.
3936
3937        // v4 DHT — only start if no active lookup
3938        if self.dht_peers_rx.is_none()
3939            && let Some(dht) = self.current_dht()
3940        {
3941            match dht.get_peers(self.info_hash).await {
3942                Ok(rx) => self.dht_peers_rx = Some(rx),
3943                Err(e) => warn!("DHT v4 re-query failed: {e}"),
3944            }
3945        }
3946
3947        // v6 DHT — only start if no active lookup
3948        if self.dht_v6_peers_rx.is_none()
3949            && self.dht_v6_empty_count < 30
3950            && self.should_retry_v6()
3951            && let Some(dht6) = self.current_dht_v6()
3952        {
3953            self.dht_v6_last_retry = Some(std::time::Instant::now());
3954            match dht6.get_peers(self.info_hash).await {
3955                Ok(rx) => self.dht_v6_peers_rx = Some(rx),
3956                Err(e) => debug!("DHT v6 re-query failed: {e}"),
3957            }
3958        }
3959
3960        // v2 swarm re-query for hybrid torrents — only start if no active lookup
3961        if self.info_hashes.is_hybrid()
3962            && let Some(v2) = self.info_hashes.v2
3963        {
3964            let v2_bytes: [u8; 20] = v2.0[..20]
3965                .try_into()
3966                .expect("Id32 is 32 bytes; first 20 always fit");
3967            let v2_as_v1 = Id20(v2_bytes);
3968
3969            if self.dht_v2_peers_rx.is_none()
3970                && let Some(dht) = self.current_dht()
3971            {
3972                match dht.get_peers(v2_as_v1).await {
3973                    Ok(rx) => self.dht_v2_peers_rx = Some(rx),
3974                    Err(e) => debug!("DHT v4 v2-swarm re-query failed: {e}"),
3975                }
3976            }
3977            if self.dht_v6_v2_peers_rx.is_none()
3978                && self.dht_v6_empty_count < 30
3979                && self.should_retry_v6()
3980                && let Some(dht6) = self.current_dht_v6()
3981            {
3982                self.dht_v6_last_retry = Some(std::time::Instant::now());
3983                match dht6.get_peers(v2_as_v1).await {
3984                    Ok(rx) => self.dht_v6_v2_peers_rx = Some(rx),
3985                    Err(e) => debug!("DHT v6 v2-swarm re-query failed: {e}"),
3986                }
3987            }
3988        }
3989
3990        debug!(peers = self.peers.len(), "DHT re-query triggered");
3991    }
3992
3993    /// Read a complete piece from disk by reading all chunks and concatenating.
3994    async fn handle_read_piece(&self, index: u32) -> crate::Result<Bytes> {
3995        let disk = self
3996            .disk
3997            .as_ref()
3998            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3999        let lengths = self
4000            .lengths
4001            .as_ref()
4002            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4003
4004        let piece_size = lengths.piece_size(index);
4005        if piece_size == 0 {
4006            return Err(crate::Error::InvalidPieceIndex {
4007                index,
4008                num_pieces: lengths.num_pieces(),
4009            });
4010        }
4011
4012        let chunk_size = lengths.chunk_size();
4013        let num_chunks = lengths.chunks_in_piece(index);
4014        let mut buf = bytes::BytesMut::with_capacity(piece_size as usize);
4015
4016        for chunk_idx in 0..num_chunks {
4017            let begin = chunk_idx * chunk_size;
4018            let len = if chunk_idx == num_chunks - 1 {
4019                piece_size - begin
4020            } else {
4021                chunk_size
4022            };
4023            let data = disk
4024                .read_chunk(index, begin, len, DiskJobFlags::empty())
4025                .await
4026                .map_err(crate::Error::Storage)?;
4027            buf.extend_from_slice(&data);
4028        }
4029
4030        Ok(buf.freeze())
4031    }
4032
4033    /// Flush the disk write cache.
4034    async fn handle_flush_cache(&self) -> crate::Result<()> {
4035        let disk = self
4036            .disk
4037            .as_ref()
4038            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4039        disk.flush_cache().await.map_err(crate::Error::Storage)
4040    }
4041
4042    /// Immediately initiate a connection to the given peer address.
4043    fn handle_connect_peer(&mut self, addr: SocketAddr) {
4044        // Skip if already connected
4045        if self.peers.contains_key(&addr) {
4046            return;
4047        }
4048        // M137: Track via unified PeerStates lifecycle
4049        if let Some(ref ps) = self.peer_states {
4050            ps.add_if_not_seen(addr, PeerSource::Incoming);
4051        }
4052    }
4053
4054    /// Fire `TrackerReply` / `TrackerError` alerts from announce outcomes.
4055    pub(crate) fn fire_tracker_alerts(&self, outcomes: &[crate::tracker_manager::TrackerOutcome]) {
4056        for outcome in outcomes {
4057            match &outcome.result {
4058                Ok(num_peers) => {
4059                    post_alert(
4060                        &self.alert_tx,
4061                        &self.alert_mask,
4062                        AlertKind::TrackerReply {
4063                            info_hash: self.info_hash,
4064                            url: outcome.url.clone(),
4065                            num_peers: *num_peers,
4066                        },
4067                    );
4068                }
4069                Err(msg) => {
4070                    post_alert(
4071                        &self.alert_tx,
4072                        &self.alert_mask,
4073                        AlertKind::TrackerError {
4074                            info_hash: self.info_hash,
4075                            url: outcome.url.clone(),
4076                            message: msg.clone(),
4077                        },
4078                    );
4079                }
4080            }
4081        }
4082    }
4083
4084    /// Calculate bytes remaining for tracker announce.
4085    pub(crate) fn calculate_left(&self) -> u64 {
4086        match (&self.meta, &self.chunk_tracker) {
4087            (Some(meta), Some(ct)) => {
4088                let total = meta.info.total_length();
4089                let have = u64::from(ct.bitfield().count_ones());
4090                let pieces_total = u64::from(self.num_pieces);
4091                let per_piece = total.checked_div(pieces_total).unwrap_or(0);
4092                total.saturating_sub(have * per_piece)
4093            }
4094            _ => 0,
4095        }
4096    }
4097
4098    pub(crate) async fn shutdown_peers(&mut self) {
4099        // Best-effort announce Stopped to trackers (with timeout to prevent hang)
4100        let left = self.calculate_left();
4101        let _ = tokio::time::timeout(
4102            std::time::Duration::from_secs(3),
4103            self.tracker_manager
4104                .announce_stopped(self.uploaded, self.downloaded, left),
4105        )
4106        .await;
4107
4108        // Non-blocking peer shutdown — peers may already be dead or channels full
4109        for peer in self.peers.values() {
4110            let _ = peer.cmd_tx.try_send(PeerCommand::Shutdown);
4111        }
4112    }
4113
4114    // ----- Event handlers -----
4115
4116    pub(crate) async fn handle_piece_data(
4117        &mut self,
4118        peer_addr: SocketAddr,
4119        index: u32,
4120        begin: u32,
4121        data: Bytes,
4122    ) {
4123        // Skip duplicate blocks — in end-game mode or after timeout re-requests,
4124        // the same block may arrive from multiple peers. Writing it to the store
4125        // buffer would overwrite valid data that's pending verification.
4126        if let Some(ref ct) = self.chunk_tracker
4127            && ct.has_chunk(index, begin)
4128        {
4129            self.total_download += data.len() as u64 + 13;
4130            // Remove from pending_requests to free pipeline slots. Without this,
4131            // the peer accumulates phantom entries from already-verified pieces
4132            // and eventually has zero available pipeline slots — permanent stall.
4133            if let Some(peer) = self.peers.get_mut(&peer_addr) {
4134                peer.pending_requests.remove(index, begin);
4135            }
4136            // Remove from end-game tracker so pick_block won't return this
4137            // block again. The normal path calls block_received which does
4138            // this, but we skip that path for duplicates.
4139            if self.end_game.is_active() {
4140                self.end_game.block_received(index, begin, peer_addr);
4141            }
4142            // M75: Permit already returned by peer task on Piece receipt
4143            return;
4144        }
4145
4146        let data_len = data.len();
4147
4148        // M100: Deferred write via per-torrent writer task.
4149        if let Some(ref disk) = self.disk {
4150            disk.write_block_deferred(index, begin, data);
4151        }
4152
4153        self.downloaded += data_len as u64;
4154        self.total_download += data_len as u64 + 13; // payload + message header
4155        self.last_download = now_unix();
4156        self.need_save_resume = true;
4157
4158        // M93: Track piece ownership (actor learns about peer's CAS reservation via chunk arrival)
4159        if let Some(slab_idx) = self.peer_slab.slot_of(&peer_addr)
4160            && self.piece_owner.get(index as usize) == Some(&None)
4161        {
4162            self.piece_owner[index as usize] = Some(slab_idx);
4163            // M149: Track when piece started downloading
4164            if self.inflight_started.get(index as usize) == Some(&None) {
4165                self.inflight_started[index as usize] = Some(Instant::now());
4166            }
4167            // M103: Add to steal queue if piece has unrequested blocks
4168            if let (Some(sc), Some(bm)) = (&self.steal_candidates, &self.block_maps)
4169                && let Some(lengths) = &self.lengths
4170            {
4171                let total_blocks = lengths.chunks_in_piece(index);
4172                if bm.next_unrequested(index, total_blocks).is_some() {
4173                    sc.push(index);
4174                }
4175            }
4176        }
4177
4178        // Smart banning: track which peers contribute to each piece
4179        self.piece_contributors
4180            .entry(index)
4181            .or_default()
4182            .insert(peer_addr.ip());
4183
4184        let now = std::time::Instant::now();
4185        if let Some(peer) = self.peers.get_mut(&peer_addr) {
4186            peer.pending_requests.remove(index, begin);
4187            peer.download_bytes_window += data_len as u64;
4188            peer.download_bytes_total += data_len as u64;
4189            peer.pipeline
4190                .block_received(index, begin, data_len as u32, now);
4191            peer.last_data_received = Some(now);
4192            // Clear snub if snubbed
4193            if peer.snubbed {
4194                peer.snubbed = false;
4195            }
4196        }
4197        // M137: Backoff is now automatically reset by mark_live() in PeerStates.
4198
4199        // End-game: cancel this block on all other peers. The 200ms end-game
4200        // refill tick will re-stock freed peers — no reactive cascade needed.
4201        if self.end_game.is_active() {
4202            let cancels = self.end_game.block_received(index, begin, peer_addr);
4203            for (cancel_addr, ci, cb, cl) in cancels {
4204                if let Some(cancel_peer) = self.peers.get_mut(&cancel_addr) {
4205                    let _ = cancel_peer.cmd_tx.try_send(PeerCommand::Cancel {
4206                        index: ci,
4207                        begin: cb,
4208                        length: cl,
4209                    });
4210                    cancel_peer.pending_requests.remove(ci, cb);
4211                }
4212            }
4213        }
4214
4215        // Track chunk completion
4216        let piece_complete = if let Some(ref mut ct) = self.chunk_tracker {
4217            ct.chunk_received(index, begin)
4218        } else {
4219            false
4220        };
4221
4222        if piece_complete && !self.pending_verify.contains(&index) {
4223            // M44/M118: Predictive piece announce — broadcast Have before verification
4224            if self.config.predictive_piece_announce_ms > 0
4225                && !self.predictive_have_sent.contains(&index)
4226            {
4227                self.predictive_have_sent.insert(index);
4228                let _ = self.have_broadcast_tx.send(index);
4229            }
4230
4231            // M100: Flush deferred writes before verification — ensures all
4232            // blocks are on disk so read_piece() sees complete data.
4233            if let Some(ref disk) = self.disk {
4234                disk.flush_piece_writes(index).await;
4235            }
4236
4237            match self.version {
4238                irontide_core::TorrentVersion::V1Only => {
4239                    // Async: fire-and-forget, result via verify_result_rx
4240                    if let Some(ref disk) = self.disk
4241                        && let Some(expected) = self
4242                            .meta
4243                            .as_ref()
4244                            .and_then(|m| m.info.piece_hash(index as usize))
4245                    {
4246                        self.pending_verify.insert(index);
4247                        let generation = self
4248                            .piece_generations
4249                            .get(index as usize)
4250                            .copied()
4251                            .unwrap_or(0);
4252                        disk.enqueue_verify(index, expected, generation, &self.verify_result_tx);
4253                    }
4254                }
4255                irontide_core::TorrentVersion::V2Only => {
4256                    // Blocking: needs mutable hash_picker for Merkle tree
4257                    self.verify_and_mark_piece_v2(index).await;
4258                }
4259                irontide_core::TorrentVersion::Hybrid => {
4260                    // Blocking: needs both v1+v2 decision matrix
4261                    self.verify_and_mark_piece_hybrid(index).await;
4262                }
4263            }
4264        }
4265
4266        // M75: Permit already returned by peer task on Piece receipt.
4267        // End-game dispatch still happens here.
4268        if self.end_game.is_active() {
4269            self.request_end_game_block(peer_addr).await;
4270        }
4271    }
4272
4273    /// M92: Process a batch of block completions from a single peer.
4274    /// Iterates blocks, calling `process_block_completion()` for each.
4275    /// Piece verifications are triggered inline as pieces complete
4276    /// (same as the former per-block path).
4277    pub(crate) async fn handle_piece_blocks_batch(
4278        &mut self,
4279        peer_addr: SocketAddr,
4280        blocks: Vec<crate::types::BlockEntry>,
4281    ) {
4282        for block in &blocks {
4283            self.process_block_completion(peer_addr, block.index, block.begin, block.length)
4284                .await;
4285        }
4286    }
4287
4288    fn handle_open_file(
4289        &mut self,
4290        file_index: usize,
4291    ) -> crate::Result<crate::streaming::FileStreamHandle> {
4292        let meta = self
4293            .meta
4294            .as_ref()
4295            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4296        let files = meta.info.files();
4297        if file_index >= files.len() {
4298            return Err(crate::Error::InvalidFileIndex {
4299                index: file_index,
4300                count: files.len(),
4301            });
4302        }
4303        if self.file_priorities.get(file_index).copied() == Some(FilePriority::Skip) {
4304            return Err(crate::Error::FileSkipped { index: file_index });
4305        }
4306
4307        let lengths = self
4308            .lengths
4309            .as_ref()
4310            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4311        let disk = self
4312            .disk
4313            .as_ref()
4314            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4315
4316        // Compute file offset within torrent data
4317        let mut file_offset = 0u64;
4318        for f in &files[..file_index] {
4319            file_offset += f.length;
4320        }
4321        let file_length = files[file_index].length;
4322
4323        let (cursor_tx, cursor_rx) = tokio::sync::watch::channel(0u64);
4324
4325        let permit = self
4326            .stream_read_semaphore
4327            .clone()
4328            .try_acquire_owned()
4329            .map_err(|_| crate::Error::Connection("too many concurrent stream readers".into()))?;
4330
4331        // Add streaming cursor for the actor to track
4332        self.streaming_cursors
4333            .push(crate::streaming::StreamingCursor {
4334                file_index,
4335                file_offset,
4336                cursor_piece: (file_offset / lengths.piece_length()) as u32,
4337                readahead_pieces: self.config.readahead_pieces,
4338                cursor_rx,
4339            });
4340
4341        Ok(crate::streaming::FileStreamHandle {
4342            disk: disk.clone(),
4343            lengths: lengths.clone(),
4344            file_index,
4345            file_offset,
4346            file_length,
4347            cursor_tx,
4348            piece_ready_rx: self.piece_ready_tx.subscribe(),
4349            have: self.have_watch_rx.clone(),
4350            read_permit: permit,
4351        })
4352    }
4353
4354    /// M44: Suggest cached pieces to connected peers (BEP 6).
4355    async fn suggest_cached_pieces(&mut self) {
4356        if !self.config.suggest_mode {
4357            return;
4358        }
4359        let disk = match self.disk {
4360            Some(ref d) => d.clone(),
4361            None => return,
4362        };
4363        let cached = disk.cached_pieces().await;
4364        if cached.is_empty() {
4365            return;
4366        }
4367        let max_suggest = self.config.max_suggest_pieces;
4368        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4369        for peer_addr in peer_addrs {
4370            let already_suggested = self.suggested_to_peers.entry(peer_addr).or_default();
4371            let peer_has_piece = |piece: u32| -> bool {
4372                self.peers
4373                    .get(&peer_addr)
4374                    .is_some_and(|p| p.bitfield.get(piece))
4375            };
4376            let mut sent = 0;
4377            for &piece in &cached {
4378                if sent >= max_suggest {
4379                    break;
4380                }
4381                if peer_has_piece(piece) {
4382                    continue;
4383                }
4384                if already_suggested.contains(&piece) {
4385                    continue;
4386                }
4387                if let Some(peer) = self.peers.get(&peer_addr) {
4388                    let _ = peer.cmd_tx.try_send(PeerCommand::SuggestPiece(piece));
4389                    already_suggested.insert(piece);
4390                    sent += 1;
4391                }
4392            }
4393        }
4394    }
4395
4396    /// M147: Handle pre-resolved metadata from the background resolver.
4397    ///
4398    /// If the `TorrentActor` is still in `FetchingMetadata` state, feed the
4399    /// info bytes through `MetadataDownloader` and call `try_assemble_metadata()`.
4400    /// If already past that state (actor resolved first), silently ignore.
4401    async fn handle_pre_resolved_metadata(&mut self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
4402        // Only act if still fetching metadata — actor may have resolved first.
4403        if self.state != TorrentState::FetchingMetadata {
4404            debug!(
4405                info_hash = %self.info_hash,
4406                state = ?self.state,
4407                "ignoring pre-resolved metadata: already past FetchingMetadata"
4408            );
4409            return;
4410        }
4411
4412        debug!(
4413            info_hash = %self.info_hash,
4414            info_bytes_len = info_bytes.len(),
4415            num_peers = peers.len(),
4416            "received pre-resolved metadata from background resolver"
4417        );
4418
4419        // Feed the complete info bytes to the MetadataDownloader.
4420        if let Some(ref mut dl) = self.metadata_downloader {
4421            // Set total size so the downloader knows the expected piece count.
4422            dl.set_total_size(info_bytes.len() as u64);
4423
4424            // Feed as a single piece (piece 0) containing the full info dict.
4425            // For metadata smaller than 16 KiB this is a single piece.
4426            // For larger metadata, feed each 16 KiB chunk as a separate piece.
4427            let piece_size: usize = 16384;
4428            let num_pieces = info_bytes.len().div_ceil(piece_size);
4429            for i in 0..num_pieces {
4430                let start = i * piece_size;
4431                let end = (start + piece_size).min(info_bytes.len());
4432                let data = bytes::Bytes::copy_from_slice(&info_bytes[start..end]);
4433                dl.piece_received(i as u32, data);
4434            }
4435        }
4436
4437        // Attempt assembly — this will transition to Downloading if
4438        // the info_hash validates.
4439        self.try_assemble_metadata().await;
4440
4441        // Pre-seed discovered peers into the pipeline.
4442        if !peers.is_empty() {
4443            self.handle_add_peers(peers, crate::peer_state::PeerSource::Dht);
4444        }
4445    }
4446
4447    pub(crate) async fn try_assemble_metadata(&mut self) {
4448        let assembled = if let Some(ref dl) = self.metadata_downloader {
4449            dl.assemble_and_verify()
4450        } else {
4451            return;
4452        };
4453
4454        match assembled {
4455            Ok(info_bytes) => {
4456                // Build torrent bytes wrapping the raw info dict into a minimal torrent
4457                // We need to parse it as a full torrent. The info_bytes is the raw bencoded
4458                // info dict. We'll build a minimal torrent around it.
4459                // Actually, torrent_from_bytes expects a full torrent dict.
4460                // Let's build one:
4461                let mut torrent_bytes = b"d4:info".to_vec();
4462                torrent_bytes.extend_from_slice(&info_bytes);
4463                torrent_bytes.push(b'e');
4464
4465                match torrent_from_bytes(&torrent_bytes) {
4466                    Ok(meta) => {
4467                        let num_pieces = meta.info.num_pieces() as u32;
4468                        let lengths = Lengths::new(
4469                            meta.info.total_length(),
4470                            meta.info.piece_length,
4471                            DEFAULT_CHUNK_SIZE,
4472                        );
4473
4474                        // Create filesystem storage now that we know the file layout
4475                        let files = meta.info.files();
4476                        let file_paths: Vec<std::path::PathBuf> = files
4477                            .iter()
4478                            .map(|f| f.path.iter().collect::<std::path::PathBuf>())
4479                            .collect();
4480                        let file_lengths_vec: Vec<u64> = files.iter().map(|f| f.length).collect();
4481                        let prealloc_mode = self.config.preallocate_mode.unwrap_or_else(|| {
4482                            irontide_storage::PreallocateMode::from(
4483                                self.config.storage_mode == irontide_core::StorageMode::Full,
4484                            )
4485                        });
4486                        let storage: Arc<dyn TorrentStorage> =
4487                            match irontide_storage::FilesystemStorage::new(
4488                                &self.config.download_dir,
4489                                file_paths,
4490                                file_lengths_vec,
4491                                lengths.clone(),
4492                                None,
4493                                prealloc_mode,
4494                                self.config.filesystem_direct_io,
4495                            ) {
4496                                Ok(s) => Arc::new(s),
4497                                Err(e) => {
4498                                    warn!(
4499                                        "failed to create filesystem storage: {e}, falling back to memory"
4500                                    );
4501                                    Arc::new(MemoryStorage::new(lengths.clone()))
4502                                }
4503                            };
4504                        let mut disk_handle = self
4505                            .disk_manager
4506                            .register_torrent(self.info_hash, storage)
4507                            .await;
4508
4509                        self.chunk_tracker = Some(ChunkTracker::new(lengths.clone()));
4510                        self.lengths = Some(lengths);
4511                        self.num_pieces = num_pieces;
4512                        // M96: Initialize real generation counters + hash result channel
4513                        self.piece_generations = vec![0u64; num_pieces as usize];
4514                        let (hash_tx, hash_rx) = tokio::sync::mpsc::channel(64);
4515                        self.hash_result_tx = hash_tx;
4516                        self.hash_result_rx = hash_rx;
4517                        // M96: Wire hash pool into disk handle (version check deferred
4518                        // until after metadata detection below sets self.version)
4519                        if let Some(ref pool) = self.hash_pool_ref {
4520                            disk_handle.set_hash_pool(pool.clone());
4521                            disk_handle.set_hash_result_tx(self.hash_result_tx.clone());
4522                        }
4523                        self.disk = Some(disk_handle);
4524                        // Update all connected peer tasks so they can validate
4525                        // incoming Bitfield messages with the correct piece count.
4526                        for peer in self.peers.values() {
4527                            let _ = peer
4528                                .cmd_tx
4529                                .try_send(PeerCommand::UpdateNumPieces(num_pieces));
4530                        }
4531                        let file_lengths: Vec<u64> =
4532                            meta.info.files().iter().map(|f| f.length).collect();
4533                        let mut meta = meta;
4534                        meta.info_bytes = Some(Bytes::from(info_bytes));
4535                        self.meta = Some(meta);
4536
4537                        // M116: Populate cached file info for zero-alloc completion checks.
4538                        if let (Some(meta), Some(lengths)) = (&self.meta, &self.lengths) {
4539                            self.cached_files = Some(build_cached_file_info(meta, lengths));
4540                        }
4541
4542                        self.file_priorities = vec![FilePriority::Normal; file_lengths.len()];
4543
4544                        // BEP 53: apply magnet so= file selection
4545                        if let Some(ref selections) = self.magnet_selected_files {
4546                            self.file_priorities = irontide_core::FileSelection::to_priorities(
4547                                selections,
4548                                file_lengths.len(),
4549                            );
4550                            self.magnet_selected_files = None;
4551                        }
4552
4553                        self.wanted_pieces = crate::piece_selector::build_wanted_pieces(
4554                            &self.file_priorities,
4555                            &file_lengths,
4556                            self.lengths.as_ref().unwrap(),
4557                        );
4558                        if self.config.share_mode {
4559                            self.transition_state(TorrentState::Sharing);
4560                        } else {
4561                            self.transition_state(TorrentState::Downloading);
4562                        }
4563                        self.metadata_downloader = None;
4564
4565                        // Populate tracker manager with newly parsed metadata
4566                        if let Some(ref meta) = self.meta {
4567                            self.tracker_manager
4568                                .set_metadata_filtered(meta, self.config.url_security);
4569                        }
4570
4571                        // Detect hybrid/v2 from metadata and update dual-swarm state
4572                        // (Gap 1 & 2: propagate info_hashes to tracker + DHT after magnet resolves)
4573                        if let Ok(detected) = irontide_core::torrent_from_bytes_any(&torrent_bytes)
4574                        {
4575                            let new_version = detected.version();
4576                            if new_version != irontide_core::TorrentVersion::V1Only {
4577                                let new_hashes = detected.info_hashes();
4578                                self.version = new_version;
4579                                self.info_hashes = new_hashes.clone();
4580                                self.tracker_manager.set_info_hashes(new_hashes.clone());
4581                                if let Some(v2_meta) = detected.as_v2() {
4582                                    self.meta_v2 = Some(v2_meta.clone());
4583                                }
4584                                // Start v2 DHT lookups for hybrid torrents
4585                                if new_hashes.is_hybrid()
4586                                    && let Some(v2) = new_hashes.v2
4587                                {
4588                                    let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
4589                                    if v2_as_v1 != self.info_hash {
4590                                        if self.dht_v2_peers_rx.is_none()
4591                                            && let Some(dht) = self.current_dht()
4592                                            && let Ok(rx) = dht.get_peers(v2_as_v1).await
4593                                        {
4594                                            self.dht_v2_peers_rx = Some(rx);
4595                                        }
4596                                        if self.dht_v6_v2_peers_rx.is_none()
4597                                            && self.dht_v6_empty_count < 30
4598                                            && self.should_retry_v6()
4599                                            && let Some(dht6) = self.current_dht_v6()
4600                                            && let Ok(rx) = dht6.get_peers(v2_as_v1).await
4601                                        {
4602                                            self.dht_v6_last_retry =
4603                                                Some(std::time::Instant::now());
4604                                            self.dht_v6_v2_peers_rx = Some(rx);
4605                                        }
4606                                    }
4607                                }
4608                            }
4609                        }
4610
4611                        let name = self
4612                            .meta
4613                            .as_ref()
4614                            .map(|m| m.info.name.clone())
4615                            .unwrap_or_default();
4616                        post_alert(
4617                            &self.alert_tx,
4618                            &self.alert_mask,
4619                            AlertKind::MetadataReceived {
4620                                info_hash: self.info_hash,
4621                                name,
4622                            },
4623                        );
4624                        info!("metadata assembled, switching to Downloading");
4625
4626                        // M93: Initialize lock-free piece states after metadata
4627                        if let Some(ct) = &self.chunk_tracker {
4628                            let atomic_states = Arc::new(AtomicPieceStates::new(
4629                                self.num_pieces,
4630                                ct.bitfield(),
4631                                &self.wanted_pieces,
4632                            ));
4633                            self.atomic_states = Some(Arc::clone(&atomic_states));
4634                            self.piece_owner = vec![None; self.num_pieces as usize];
4635                            // M149: Initialize inflight tracking
4636                            self.inflight_started = vec![None; self.num_pieces as usize];
4637                            self.max_in_flight = self.config.max_in_flight_pieces;
4638
4639                            // M103: Initialize block stealing infrastructure
4640                            if self.config.use_block_stealing {
4641                                if let Some(ref lengths) = self.lengths {
4642                                    self.block_maps =
4643                                        Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
4644                                }
4645                                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
4646                            }
4647                            // M120: Per-piece write guards
4648                            self.piece_write_guards = Some(Arc::new(
4649                                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
4650                            ));
4651
4652                            // M187: Init direct-acquire dispatch state.
4653                            self.piece_tracker = Some(PieceTracker::new(
4654                                self.num_pieces,
4655                                ct.bitfield(),
4656                                &self.wanted_pieces,
4657                            ));
4658                            if let Some(ref cached) = self.cached_files {
4659                                let file_piece_ranges: Vec<(u32, u32)> = cached
4660                                    .entries
4661                                    .iter()
4662                                    .map(|e| (e.first_piece, e.last_piece))
4663                                    .collect();
4664                                let om = Arc::new(PieceOrderMap::build(
4665                                    &self.file_priorities,
4666                                    &file_piece_ranges,
4667                                    self.num_pieces,
4668                                    0,
4669                                ));
4670                                self.order_map_tx.send_replace(om);
4671                            }
4672
4673                            let notify = Arc::new(tokio::sync::Notify::new());
4674                            self.reservation_notify = Some(notify);
4675                        }
4676
4677                        // Start web seeds now that we have metadata
4678                        self.spawn_web_seeds();
4679                        self.assign_pieces_to_web_seeds();
4680
4681                        // Kick-start piece requesting for all peers that connected during
4682                        // metadata phase. Send StartRequesting to all connected peers.
4683                        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4684                        info!(
4685                            connected_peers = peer_addrs.len(),
4686                            "kick-starting piece requests for pre-connected peers"
4687                        );
4688                        for addr in peer_addrs {
4689                            let has_bitfield =
4690                                self.peers.get(&addr).map_or(0, |p| p.bitfield.count_ones());
4691                            let is_choking = self.peers.get(&addr).is_none_or(|p| p.peer_choking);
4692                            debug!(%addr, has_bitfield, is_choking, "post-metadata peer state");
4693                            self.maybe_express_interest(addr).await;
4694                            if let Some(peer) = self.peers.get(&addr)
4695                                && peer.bitfield.count_ones() > 0
4696                            {
4697                                let _slot = self.peer_slab.insert(addr);
4698                            }
4699                        }
4700                        self.recalc_max_in_flight();
4701                        // M93: Inform all connected peers about lock-free dispatch state.
4702                        // M159: Skip while user seed mode is active — we are currently
4703                        // not scheduling any new block requests.
4704                        if !self.user_seed_mode
4705                            && let Some(notify) = &self.reservation_notify
4706                            && let Some(ref lengths) = self.lengths
4707                        {
4708                            for peer in self.peers.values() {
4709                                let _ = peer.cmd_tx.try_send(PeerCommand::StartRequesting {
4710                                    piece_notify: Arc::clone(notify),
4711                                    disk_handle: self.disk.clone(),
4712                                    write_error_tx: self.write_error_tx.clone(),
4713                                    lengths: lengths.clone(),
4714                                });
4715                            }
4716                        }
4717                    }
4718                    Err(e) => {
4719                        warn!("failed to parse assembled metadata: {e}");
4720                        post_alert(
4721                            &self.alert_tx,
4722                            &self.alert_mask,
4723                            AlertKind::MetadataFailed {
4724                                info_hash: self.info_hash,
4725                            },
4726                        );
4727                    }
4728                }
4729            }
4730            Err(e) => {
4731                warn!("metadata assembly failed: {e}");
4732                post_alert(
4733                    &self.alert_tx,
4734                    &self.alert_mask,
4735                    AlertKind::MetadataFailed {
4736                        info_hash: self.info_hash,
4737                    },
4738                );
4739            }
4740        }
4741    }
4742
4743    // ----- Web seeding (M22) -----
4744
4745    fn spawn_web_seeds(&mut self) {
4746        if !self.config.enable_web_seed {
4747            return;
4748        }
4749        let Some(meta) = &self.meta else { return };
4750        let lengths = match &self.lengths {
4751            Some(l) => l.clone(),
4752            None => return,
4753        };
4754
4755        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
4756        let file_map = irontide_storage::FileMap::new(file_lengths, lengths.clone());
4757
4758        // BEP 19 (GetRight) web seeds
4759        for url in &meta.url_list {
4760            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4761                continue;
4762            }
4763            if self.web_seeds.len() >= self.config.max_web_seeds {
4764                break;
4765            }
4766
4767            // Security validation
4768            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4769                warn!(%url, %e, "web seed URL rejected by security policy");
4770                continue;
4771            }
4772
4773            let url_builder = if meta.info.length.is_some() {
4774                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone())
4775            } else {
4776                let file_paths: Vec<String> = meta
4777                    .info
4778                    .files()
4779                    .iter()
4780                    .map(|f| f.path[1..].join("/")) // skip torrent name prefix
4781                    .collect();
4782                crate::web_seed::WebSeedUrlBuilder::multi(
4783                    url.clone(),
4784                    meta.info.name.clone(),
4785                    file_paths,
4786                )
4787            };
4788
4789            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4790            let initial_downloaded = self
4791                .web_seed_stats
4792                .get(url)
4793                .map_or(0, |s| s.downloaded_bytes);
4794            let task = crate::web_seed::WebSeedTask::new(
4795                url.clone(),
4796                crate::web_seed::WebSeedMode::GetRight,
4797                url_builder,
4798                lengths.clone(),
4799                file_map.clone(),
4800                self.info_hash,
4801                cmd_rx,
4802                self.event_tx.clone(),
4803                self.config.url_security,
4804                self.config.web_seed_progress_throttle_ms,
4805                initial_downloaded,
4806                self.config.web_seed_retry_base_secs,
4807                self.config.web_seed_retry_factor,
4808                self.config.web_seed_retry_cap_secs,
4809                self.config.web_seed_max_failures,
4810            );
4811            tokio::spawn(task.run());
4812            self.web_seeds.insert(url.clone(), cmd_tx);
4813            debug!(url, "spawned BEP 19 web seed");
4814        }
4815
4816        // BEP 17 (Hoffman) HTTP seeds
4817        for url in &meta.httpseeds {
4818            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4819                continue;
4820            }
4821            if self.web_seeds.len() >= self.config.max_web_seeds {
4822                break;
4823            }
4824
4825            // Security validation
4826            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4827                warn!(%url, %e, "web seed URL rejected by security policy");
4828                continue;
4829            }
4830
4831            // BEP 17 doesn't use URL builder for per-file paths; it sends parameterized URLs
4832            let url_builder =
4833                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone());
4834
4835            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4836            let initial_downloaded = self
4837                .web_seed_stats
4838                .get(url)
4839                .map_or(0, |s| s.downloaded_bytes);
4840            let task = crate::web_seed::WebSeedTask::new(
4841                url.clone(),
4842                crate::web_seed::WebSeedMode::Hoffman,
4843                url_builder,
4844                lengths.clone(),
4845                file_map.clone(),
4846                self.info_hash,
4847                cmd_rx,
4848                self.event_tx.clone(),
4849                self.config.url_security,
4850                self.config.web_seed_progress_throttle_ms,
4851                initial_downloaded,
4852                self.config.web_seed_retry_base_secs,
4853                self.config.web_seed_retry_factor,
4854                self.config.web_seed_retry_cap_secs,
4855                self.config.web_seed_max_failures,
4856            );
4857            tokio::spawn(task.run());
4858            self.web_seeds.insert(url.clone(), cmd_tx);
4859            debug!(url, "spawned BEP 17 web seed");
4860        }
4861    }
4862
4863    pub(crate) fn assign_pieces_to_web_seeds(&mut self) {
4864        if self.state != TorrentState::Downloading || self.end_game.is_active() {
4865            return;
4866        }
4867
4868        // Collect idle web seed URLs (not currently downloading a piece)
4869        let active_urls: HashSet<&String> = self.web_seed_in_flight.values().collect();
4870        let idle_urls: Vec<String> = self
4871            .web_seeds
4872            .keys()
4873            .filter(|u| !active_urls.contains(u))
4874            .cloned()
4875            .collect();
4876
4877        let Some(ct) = &self.chunk_tracker else {
4878            return;
4879        };
4880
4881        for url in idle_urls {
4882            // Find lowest-index piece that is: not verified, not reserved by a peer,
4883            // not in web_seed_in_flight, and wanted.
4884            let piece = (0..self.num_pieces).find(|&i| {
4885                !ct.has_piece(i)
4886                    && !self
4887                        .piece_owner
4888                        .get(i as usize)
4889                        .is_some_and(std::option::Option::is_some)
4890                    && !self.web_seed_in_flight.contains_key(&i)
4891                    && self.wanted_pieces.get(i)
4892            });
4893
4894            if let Some(piece) = piece
4895                && let Some(cmd_tx) = self.web_seeds.get(&url)
4896            {
4897                let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::FetchPiece(piece));
4898                self.web_seed_in_flight.insert(piece, url);
4899            }
4900        }
4901    }
4902
4903    pub(crate) async fn handle_web_seed_piece_data(
4904        &mut self,
4905        url: String,
4906        index: u32,
4907        data: Bytes,
4908    ) {
4909        self.web_seed_in_flight.remove(&index);
4910
4911        // If peer already completed this piece, discard
4912        if let Some(ref ct) = self.chunk_tracker
4913            && ct.has_piece(index)
4914        {
4915            self.assign_pieces_to_web_seeds();
4916            return;
4917        }
4918
4919        // Write entire piece to disk at offset 0
4920        if let Some(ref disk) = self.disk
4921            && let Err(e) = disk
4922                .write_chunk(index, 0, data.clone(), DiskJobFlags::FLUSH_PIECE)
4923                .await
4924        {
4925            warn!(index, "web seed: failed to write piece: {e}");
4926            self.assign_pieces_to_web_seeds();
4927            return;
4928        }
4929
4930        // Mark all chunks as received
4931        if let Some(ref mut ct) = self.chunk_tracker
4932            && let Some(ref lengths) = self.lengths
4933        {
4934            let num_chunks = lengths.chunks_in_piece(index);
4935            for chunk_idx in 0..num_chunks {
4936                if let Some((begin, _len)) = lengths.chunk_info(index, chunk_idx) {
4937                    ct.chunk_received(index, begin);
4938                }
4939            }
4940        }
4941
4942        self.downloaded += data.len() as u64;
4943        self.total_download += data.len() as u64 + 13; // payload + message header
4944        self.last_download = now_unix();
4945        self.need_save_resume = true;
4946
4947        // Verify the piece hash
4948        self.verify_and_mark_piece(index).await;
4949
4950        // If hash failed, ban this web seed (BEP 19 spec)
4951        if let Some(ref ct) = self.chunk_tracker
4952            && !ct.has_piece(index)
4953        {
4954            self.ban_web_seed(&url);
4955            return;
4956        }
4957
4958        self.assign_pieces_to_web_seeds();
4959    }
4960
4961    pub(crate) fn handle_web_seed_error(&mut self, url: &str, piece: u32, message: &str) {
4962        self.web_seed_in_flight.remove(&piece);
4963        warn!(%url, piece, %message, "web seed error");
4964        self.assign_pieces_to_web_seeds();
4965    }
4966
4967    /// M178: Update per-URL `WebSeedStats` from a `WebSeedProgress` event.
4968    ///
4969    /// State machine: Idle → Active on first success; Active → Errored on
4970    /// failure; Errored → Active on recovery (`last_error` PERSISTS through
4971    /// recovery per Issue 2.2 / D-eng-8). `consecutive_failures` increments
4972    /// monotonically within a failure run and resets to zero on success.
4973    /// `last_attempt_unix_secs` updates on every event regardless of outcome.
4974    pub(crate) fn handle_web_seed_progress(
4975        &mut self,
4976        url: &str,
4977        bytes: u64,
4978        rate_bps: u64,
4979        error: Option<String>,
4980    ) {
4981        let now_unix = std::time::SystemTime::now()
4982            .duration_since(std::time::UNIX_EPOCH)
4983            .map_or(0, |d| d.as_secs());
4984        let entry = self
4985            .web_seed_stats
4986            .entry(url.to_owned())
4987            .or_insert_with(|| irontide_core::WebSeedStats {
4988                url: url.to_owned(),
4989                ..Default::default()
4990            });
4991        entry.downloaded_bytes = bytes;
4992        entry.last_rate_bps = rate_bps;
4993        entry.last_attempt_unix_secs = now_unix;
4994        if let Some(msg) = error {
4995            entry.state = irontide_core::WebSeedState::Errored;
4996            entry.last_error = Some(msg);
4997            entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
4998            // M186: Populate next_retry_unix_secs during backoff
4999            let attempt = entry.consecutive_failures.saturating_sub(1);
5000            let secs = self
5001                .config
5002                .web_seed_retry_base_secs
5003                .saturating_mul(self.config.web_seed_retry_factor.saturating_pow(attempt))
5004                .min(self.config.web_seed_retry_cap_secs);
5005            entry.next_retry_unix_secs = Some(now_unix + secs);
5006        } else {
5007            entry.state = irontide_core::WebSeedState::Active;
5008            entry.consecutive_failures = 0;
5009            entry.next_retry_unix_secs = None;
5010        }
5011        self.need_save_resume = true;
5012    }
5013
5014    pub(crate) fn ban_web_seed(&mut self, url: &str) {
5015        warn!(%url, "banning web seed due to hash failure");
5016        self.banned_web_seeds.insert(url.to_owned());
5017
5018        // Send shutdown to the task
5019        if let Some(cmd_tx) = self.web_seeds.remove(url) {
5020            let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::Shutdown);
5021        }
5022
5023        // Remove all in-flight pieces for this URL
5024        self.web_seed_in_flight.retain(|_, v| v != url);
5025
5026        post_alert(
5027            &self.alert_tx,
5028            &self.alert_mask,
5029            AlertKind::WebSeedBanned {
5030                info_hash: self.info_hash,
5031                url: url.to_owned(),
5032            },
5033        );
5034    }
5035
5036    async fn shutdown_web_seeds(&mut self) {
5037        for (_, cmd_tx) in self.web_seeds.drain() {
5038            let _ = cmd_tx.send(crate::web_seed::WebSeedCommand::Shutdown).await;
5039        }
5040        self.web_seed_in_flight.clear();
5041    }
5042
5043    /// Rebuild the cached peer rates map from current peer state.
5044    fn refresh_peer_rates(&mut self) {
5045        self.cached_peer_rates.clear();
5046        self.cached_peer_rates.reserve(self.peers.len());
5047        for (&addr, p) in &self.peers {
5048            self.cached_peer_rates.insert(addr, p.pipeline.ewma_rate());
5049        }
5050    }
5051
5052    // ----- Choking -----
5053
5054    fn update_peer_rates(&mut self) {
5055        for peer in self.peers.values_mut() {
5056            peer.download_rate = peer.download_bytes_window / 2;
5057            peer.upload_rate = peer.upload_bytes_window / 2;
5058            peer.download_bytes_window = 0;
5059            peer.upload_bytes_window = 0;
5060        }
5061
5062        // Track peak download rate for peer turnover cutoff
5063        let aggregate_download: u64 = self.peers.values().map(|p| p.download_rate).sum();
5064        if aggregate_download > self.peak_download_rate {
5065            self.peak_download_rate = aggregate_download;
5066        }
5067    }
5068
5069    async fn run_choker(&mut self) {
5070        let peer_infos: Vec<ChokerPeerInfo> = self
5071            .peers
5072            .values()
5073            .map(|p| ChokerPeerInfo {
5074                addr: p.addr,
5075                download_rate: p.download_rate,
5076                upload_rate: p.upload_rate,
5077                interested: p.peer_interested,
5078                upload_only: p.upload_only,
5079                is_seed: p.upload_only
5080                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5081            })
5082            .collect();
5083
5084        let decision = self.choker.decide(&peer_infos);
5085
5086        for addr in &decision.to_unchoke {
5087            if let Some(peer) = self.peers.get_mut(addr)
5088                && peer.am_choking
5089            {
5090                peer.am_choking = false;
5091                // Track unchoke window for fairness measurement.
5092                if peer.am_unchoke_started_at.is_none() {
5093                    peer.am_unchoke_started_at = Some(Instant::now());
5094                }
5095                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(false));
5096            }
5097        }
5098
5099        for addr in &decision.to_choke {
5100            if let Some(peer) = self.peers.get_mut(addr)
5101                && !peer.am_choking
5102            {
5103                if peer.supports_fast {
5104                    let pending: Vec<(u32, u32, u32)> = peer.incoming_requests.drain(..).collect();
5105                    for (index, begin, length) in pending {
5106                        let _ = peer.cmd_tx.try_send(PeerCommand::RejectRequest {
5107                            index,
5108                            begin,
5109                            length,
5110                        });
5111                    }
5112                }
5113                peer.am_choking = true;
5114                // Accumulate the unchoke window we just closed.
5115                if let Some(start) = peer.am_unchoke_started_at.take() {
5116                    peer.unchoke_duration_total += start.elapsed();
5117                }
5118                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(true));
5119            }
5120        }
5121
5122        // Serve any buffered requests from newly-unchoked peers
5123        self.serve_incoming_requests().await;
5124
5125        // Zombie pruning: disconnect peers with empty bitfields after 30s.
5126        // These peers consume connection slots but contribute no pieces.
5127        // Only prune during downloading — when seeding, empty-bitfield peers
5128        // are leechers we want to upload to.
5129        if self.state == TorrentState::Downloading {
5130            let zombie_threshold = Duration::from_secs(30);
5131            let zombies: Vec<SocketAddr> = self
5132                .peers
5133                .values()
5134                .filter(|p| {
5135                    p.bitfield.count_ones() == 0 && p.connected_at.elapsed() > zombie_threshold
5136                })
5137                .map(|p| p.addr)
5138                .collect();
5139
5140            for &addr in &zombies {
5141                debug!(%addr, "disconnecting zombie peer (empty bitfield after 30s)");
5142                self.disconnect_peer(addr, "zombie peer (empty bitfield)");
5143            }
5144            if !zombies.is_empty() {
5145                self.recalc_max_in_flight();
5146            }
5147        }
5148    }
5149
5150    fn rotate_optimistic(&mut self) {
5151        let peer_infos: Vec<ChokerPeerInfo> = self
5152            .peers
5153            .values()
5154            .map(|p| ChokerPeerInfo {
5155                addr: p.addr,
5156                download_rate: p.download_rate,
5157                upload_rate: p.upload_rate,
5158                interested: p.peer_interested,
5159                upload_only: p.upload_only,
5160                is_seed: p.upload_only
5161                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5162            })
5163            .collect();
5164
5165        self.choker.rotate_optimistic(&peer_infos);
5166    }
5167
5168    /// Handle an incoming I2P peer connection (M41).
5169    ///
5170    /// Assigns a synthetic `SocketAddr` (from the reserved 240.0.0.0/4 range) since
5171    /// I2P peers don't have real IP addresses, then hands the underlying TCP stream
5172    /// to `spawn_peer_from_stream`.
5173    fn handle_i2p_incoming(&mut self, stream: crate::i2p::SamStream) {
5174        if self.peers.len() >= self.effective_max_connections() {
5175            return;
5176        }
5177
5178        let synthetic_addr = self.next_i2p_synthetic_addr();
5179
5180        let remote_dest = stream.remote_destination().clone();
5181        let dest_preview = {
5182            let b64 = remote_dest.to_base64();
5183            if b64.len() >= 8 {
5184                b64[..8].to_string()
5185            } else {
5186                b64
5187            }
5188        };
5189        self.i2p_destinations.insert(synthetic_addr, remote_dest);
5190        let tcp_stream = stream.into_inner();
5191
5192        self.spawn_peer_from_stream(synthetic_addr, tcp_stream);
5193
5194        debug!(dest = %dest_preview, addr = %synthetic_addr, "accepted I2P peer");
5195    }
5196
5197    /// Add an I2P peer by destination, assigning a synthetic `SocketAddr`.
5198    #[allow(dead_code)] // Used by Task 2 (outbound I2P connects)
5199    fn add_i2p_peer(
5200        &mut self,
5201        dest: crate::i2p::I2pDestination,
5202        source: PeerSource,
5203    ) -> Option<SocketAddr> {
5204        // Dedup: check if we already track this destination
5205        if self.i2p_destinations.values().any(|d| d == &dest) {
5206            return None;
5207        }
5208        let addr = self.next_i2p_synthetic_addr();
5209        self.i2p_destinations.insert(addr, dest);
5210        // M137: Track via unified PeerStates lifecycle
5211        if let Some(ref ps) = self.peer_states {
5212            ps.add_if_not_seen(addr, source);
5213        }
5214        Some(addr)
5215    }
5216
5217    /// Generate a unique synthetic `SocketAddr` for an I2P peer.
5218    ///
5219    /// Uses addresses from 240.0.0.0/4 (reserved, never routable) to avoid
5220    /// conflicts with real peers. The counter ensures uniqueness across the
5221    /// torrent's lifetime.
5222    fn next_i2p_synthetic_addr(&mut self) -> SocketAddr {
5223        self.i2p_peer_counter = self.i2p_peer_counter.wrapping_add(1);
5224        let a = ((self.i2p_peer_counter >> 16) & 0x0F) as u8 | 0xF0;
5225        let b = ((self.i2p_peer_counter >> 8) & 0xFF) as u8;
5226        let c = (self.i2p_peer_counter & 0xFF) as u8;
5227        SocketAddr::new(
5228            std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, 1)),
5229            (self.i2p_peer_counter & 0xFFFF) as u16,
5230        )
5231    }
5232}
5233
5234/// Check whether a `SocketAddr` uses a synthetic I2P address (240.0.0.0/4 range).
5235pub(crate) fn is_i2p_synthetic_addr(addr: &SocketAddr) -> bool {
5236    match addr {
5237        SocketAddr::V4(v4) => v4.ip().octets()[0] & 0xF0 == 0xF0,
5238        SocketAddr::V6(_) => false,
5239    }
5240}
5241
5242/// Helper to accept a connection from an optional transport listener.
5243/// Returns `pending` if no listener is bound, so the `select!` branch is skipped.
5244async fn accept_incoming(
5245    listener: &mut Option<Box<dyn crate::transport::TransportListener>>,
5246) -> std::io::Result<(crate::transport::BoxedStream, SocketAddr)> {
5247    match listener {
5248        Some(l) => l.accept().await,
5249        None => std::future::pending().await,
5250    }
5251}
5252
5253/// Helper to receive an incoming I2P connection from the accept loop channel.
5254/// Returns `pending` if I2P is not enabled, so the `select!` branch is skipped.
5255async fn accept_i2p(
5256    rx: &mut Option<mpsc::Receiver<crate::i2p::SamStream>>,
5257) -> Option<crate::i2p::SamStream> {
5258    match rx {
5259        Some(rx) => rx.recv().await,
5260        None => std::future::pending().await,
5261    }
5262}
5263
5264// ============================================================================
5265// BEP 52 hash serving (M87)
5266// ============================================================================
5267
5268/// Determine what to serve for a BEP 52 hash request.
5269///
5270/// Returns `Some(hashes)` to serve, or `None` to reject.
5271/// Only serves piece-layer hashes (the layer stored in `piece_layers`).
5272/// Block-layer or other layer requests are rejected since we don't store
5273/// the full Merkle tree.
5274pub(crate) fn serve_hashes(
5275    meta_v2: Option<&irontide_core::TorrentMetaV2>,
5276    version: irontide_core::TorrentVersion,
5277    lengths: Option<&Lengths>,
5278    request: &irontide_core::HashRequest,
5279) -> Option<Vec<irontide_core::Id32>> {
5280    // Reject if v1-only or no v2 metadata
5281    let meta_v2 = match meta_v2 {
5282        Some(m) if version != irontide_core::TorrentVersion::V1Only => m,
5283        _ => return None,
5284    };
5285
5286    // Look up piece-layer hashes for the requested file root
5287    let piece_hashes = meta_v2.file_piece_hashes(&request.file_root)?;
5288
5289    // We need lengths to validate the request geometry
5290    let lengths = lengths?;
5291
5292    // Compute per-file block count from piece hashes and piece/chunk sizes.
5293    // Each piece hash covers `piece_length / chunk_size` blocks, except the
5294    // last piece which may cover fewer. For validation purposes we use the
5295    // padded count that `validate_hash_request` expects.
5296    let blocks_per_piece = (meta_v2.info.piece_length / u64::from(lengths.chunk_size())) as u32;
5297    let num_pieces = piece_hashes.len() as u32;
5298    let num_blocks = num_pieces.saturating_mul(blocks_per_piece);
5299
5300    if !irontide_core::validate_hash_request(request, num_blocks, num_pieces) {
5301        return None;
5302    }
5303
5304    // We only have piece-layer hashes. The piece layer is at
5305    // base = log2(blocks_per_piece). Reject requests for other layers.
5306    let piece_layer_base = blocks_per_piece.trailing_zeros();
5307    if request.base != piece_layer_base {
5308        return None;
5309    }
5310
5311    // Extract requested hashes from the piece layer
5312    let start = request.index as usize;
5313    let end = (start + request.count as usize).min(piece_hashes.len());
5314    let mut hashes: Vec<irontide_core::Id32> = piece_hashes[start..end].to_vec();
5315
5316    // Compute proof (uncle) hashes if requested.
5317    //
5318    // BEP 52 specifies a single subtree proof for the entire batch, not
5319    // per-leaf proofs. The receiver rebuilds the subtree root from the
5320    // base hashes itself, so we skip the first `log2(count)` levels of
5321    // the proof path (those are internal to the requested subtree) and
5322    // only send the uncle hashes above it.
5323    if request.proof_layers > 0 && !piece_hashes.is_empty() {
5324        let tree = irontide_core::MerkleTree::from_leaves(&piece_hashes);
5325        let full_proof = tree.proof_path(start);
5326        // Skip levels internal to the requested subtree
5327        let subtree_depth = if request.count > 1 {
5328            (request.count as usize)
5329                .next_power_of_two()
5330                .trailing_zeros() as usize
5331        } else {
5332            0
5333        };
5334        let available = full_proof.len().saturating_sub(subtree_depth);
5335        let proof_count = (request.proof_layers as usize).min(available);
5336        hashes.extend_from_slice(&full_proof[subtree_depth..subtree_depth + proof_count]);
5337    }
5338
5339    Some(hashes)
5340}
5341
5342// ============================================================================
5343// Test-only constructors
5344// ============================================================================
5345
5346#[cfg(test)]
5347impl TorrentActor {
5348    /// v0.173.3 (A4): Build a minimal `TorrentActor` exercising only the
5349    /// fields touched by `rebuild_availability_snapshot`.
5350    ///
5351    /// Every other field is filled with the cheapest valid placeholder
5352    /// (empty channels, zero atomics, no-op handles). The actor is **not**
5353    /// spawned via `tokio::spawn` so it has no live `run()` loop — the
5354    /// returned struct is suitable for direct method-level testing only.
5355    ///
5356    /// `num_pieces` controls the size of the pre-allocated availability
5357    /// vector and atomic-states bitmap. `throttle_ms` plumbs the v0.173.3
5358    /// throttle config into the synthetic actor's `TorrentConfig`.
5359    ///
5360    /// Must run inside a tokio runtime because `DiskManagerHandle::new`
5361    /// internally spawns its background actor.
5362    pub(crate) fn for_throttle_test(num_pieces: u32, _throttle_ms: u64) -> Self {
5363        use irontide_storage::Bitfield;
5364
5365        let config = TorrentConfig {
5366            ..TorrentConfig::default()
5367        };
5368
5369        let info_hash = Id20([0u8; 20]);
5370        let our_peer_id = Id20([0u8; 20]);
5371
5372        let (_cmd_tx, cmd_rx) = mpsc::channel(1);
5373        let (event_tx, event_rx) = mpsc::channel(1);
5374        let (write_error_tx, write_error_rx) = mpsc::channel(1);
5375        let (verify_result_tx, verify_result_rx) = mpsc::channel(1);
5376        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
5377        let (piece_ready_tx, _piece_ready_rx) = broadcast::channel(1);
5378        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(num_pieces));
5379        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
5380        let (alert_tx, _alert_rx) = broadcast::channel(64);
5381        let (_disk_mgr_tx, _disk_mgr_rx) = mpsc::channel::<crate::disk::DiskJob>(1);
5382
5383        let stream_read_semaphore = Arc::new(tokio::sync::Semaphore::new(8));
5384        let alert_mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5385
5386        // DiskManagerHandle::new spawns an actor — requires runtime.
5387        let (disk_manager, _disk_join) =
5388            crate::disk::DiskManagerHandle::new(crate::disk::DiskConfig::default());
5389
5390        let ban_manager = Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5391            crate::ban::BanConfig::default(),
5392        )));
5393        let ip_filter = Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
5394
5395        let upload_bucket = crate::rate_limiter::TokenBucket::new(0);
5396        let download_bucket = Arc::new(parking_lot::Mutex::new(
5397            crate::rate_limiter::TokenBucket::new(0),
5398        ));
5399        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(0, 0, 0, 0, 0, 0);
5400
5401        let dht_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5402        let dht_v6_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5403        let factory = Arc::new(crate::transport::NetworkFactory::tokio());
5404
5405        // Atomic states + availability sized for `num_pieces`. The
5406        // availability snapshot rebuild reads both; everything else
5407        // (chunk tracker, file priorities, peers) can stay empty.
5408        let we_have = Bitfield::new(num_pieces);
5409        let mut wanted = Bitfield::new(num_pieces);
5410        for i in 0..num_pieces {
5411            wanted.set(i);
5412        }
5413        let atomic_states = Arc::new(crate::piece_reservation::AtomicPieceStates::new(
5414            num_pieces, &we_have, &wanted,
5415        ));
5416
5417        let (order_map_tx, _order_map_rx_seed) =
5418            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
5419
5420        Self {
5421            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(0),
5422            config,
5423            info_hash,
5424            our_peer_id,
5425            state: TorrentState::Downloading,
5426            disk: None,
5427            disk_manager,
5428            chunk_tracker: None,
5429            lengths: None,
5430            num_pieces,
5431            file_priorities: Vec::new(),
5432            wanted_pieces: Bitfield::new(num_pieces),
5433            end_game: EndGame::new(),
5434            streaming_pieces: BTreeSet::new(),
5435            time_critical_pieces: BTreeSet::new(),
5436            streaming_cursors: Vec::new(),
5437            piece_ready_tx,
5438            have_watch_tx,
5439            have_watch_rx,
5440            stream_read_semaphore,
5441            peers: HashMap::new(),
5442            unchoke_durations: HashMap::new(),
5443            cached_peer_rates: FxHashMap::default(),
5444            refill_notify: Arc::new(tokio::sync::Notify::new()),
5445            atomic_states: Some(atomic_states),
5446            block_maps: None,
5447            steal_candidates: None,
5448            last_steal_populate: Instant::now(),
5449            piece_write_guards: None,
5450            soft_reap_buf: Vec::new(),
5451            eviction_history: std::collections::VecDeque::new(),
5452            force_immediate_choker_tick: false,
5453            piece_tracker: None,
5454            order_map_dirty: false,
5455            next_order_map_gen: 0,
5456            order_map_tx,
5457            piece_owner: vec![None; num_pieces as usize],
5458            peer_slab: crate::piece_reservation::PeerSlab::new(),
5459            priority_pieces: BTreeSet::new(),
5460            max_in_flight: 512,
5461            reservation_notify: None,
5462            last_tick_dispatch_state: None,
5463            choker: Choker::new(4),
5464            user_seed_mode: false,
5465            user_forced: false,
5466            max_connections: 0,
5467            peer_states: None,
5468            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
5469            connect_permits: HashMap::new(),
5470            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
5471                std::collections::HashMap::new(),
5472            )),
5473            connect_rx: None,
5474            metadata_downloader: None,
5475            meta: None,
5476            cached_files: None,
5477            downloaded: 0,
5478            uploaded: 0,
5479            checking_progress: 0.0,
5480            total_download: 0,
5481            total_upload: 0,
5482            total_failed_bytes: 0,
5483            total_redundant_bytes: 0,
5484            added_time: 0,
5485            completed_time: 0,
5486            last_download: 0,
5487            last_upload: 0,
5488            last_seen_complete: 0,
5489            active_duration: 0,
5490            finished_duration: 0,
5491            seeding_duration: 0,
5492            active_since: None,
5493            state_duration_since: None,
5494            started_at: Instant::now(),
5495            moving_storage: false,
5496            has_incoming: false,
5497            need_save_resume: false,
5498            error: String::new(),
5499            error_file: -1,
5500            cmd_rx,
5501            event_tx,
5502            event_rx,
5503            write_error_rx,
5504            write_error_tx,
5505            verify_result_rx,
5506            verify_result_tx,
5507            pending_verify: HashSet::new(),
5508            piece_generations: vec![0u64; num_pieces as usize],
5509            hash_result_rx,
5510            hash_result_tx,
5511            listener: None,
5512            utp_socket: None,
5513            utp_socket_v6: None,
5514            tracker_manager: TrackerManager::empty(info_hash, our_peer_id, 0, 0, false),
5515            tracker_result_rx: None,
5516            dht_rx,
5517            dht_v6_rx,
5518            dht_enabled: false,
5519            dht_peers_rx: None,
5520            dht_v6_peers_rx: None,
5521            dht_v6_empty_count: 0,
5522            dht_v6_last_retry: None,
5523            alert_tx,
5524            alert_mask,
5525            upload_bucket,
5526            download_bucket,
5527            global_upload_bucket: None,
5528            global_download_bucket: None,
5529            slot_tuner: crate::slot_tuner::SlotTuner::disabled(4),
5530            upload_bytes_interval: 0,
5531            peak_download_rate: 0,
5532            web_seeds: HashMap::new(),
5533            banned_web_seeds: HashSet::new(),
5534            web_seed_in_flight: HashMap::new(),
5535            web_seed_stats: HashMap::new(),
5536            pex_peer_count: 0,
5537            lsd_peer_count: 0,
5538            super_seed: None,
5539            have_broadcast_tx,
5540            suggested_to_peers: HashMap::new(),
5541            predictive_have_sent: HashSet::new(),
5542            ban_manager,
5543            piece_contributors: HashMap::new(),
5544            parole_pieces: HashMap::new(),
5545            ip_filter,
5546            external_ip: None,
5547            share_lru: std::collections::VecDeque::new(),
5548            share_max_pieces: 0,
5549            plugins: Arc::new(Vec::new()),
5550            hash_picker: None,
5551            version: irontide_core::TorrentVersion::V1Only,
5552            meta_v2: None,
5553            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
5554            dht_v2_peers_rx: None,
5555            dht_v6_v2_peers_rx: None,
5556            magnet_selected_files: None,
5557            sam_session: None,
5558            i2p_accept_rx: None,
5559            i2p_peer_counter: 0,
5560            i2p_destinations: HashMap::new(),
5561            ssl_manager: None,
5562            rate_limiter_set,
5563            auto_sequential_active: false,
5564            factory,
5565            hash_pool_ref: None,
5566            connect_attempts: 0,
5567            connect_failures: 0,
5568            choke_rotations: 0,
5569            inflight_started: Vec::new(),
5570            completed_piece_times: std::collections::VecDeque::new(),
5571            piece_steals: 0,
5572            holepunch_relayed: 0,
5573            holepunch_relay_rate: HashMap::new(),
5574            holepunch_cooldowns: HashMap::new(),
5575            holepunch_pending: Vec::new(),
5576            counters: Arc::new(crate::stats::SessionCounters::new()),
5577        }
5578    }
5579}
5580
5581// ============================================================================
5582// Tests
5583// ============================================================================
5584
5585#[cfg(test)]
5586mod tests {
5587    use super::*;
5588    use bytes::Bytes;
5589    use futures::{SinkExt, StreamExt};
5590    use irontide_wire::{ExtHandshake, Handshake, Message, MessageCodec};
5591    use std::time::Duration;
5592    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5593    use tokio::net::TcpListener;
5594    use tokio_util::codec::{FramedRead, FramedWrite};
5595
5596    // M224: initial unchoke slot derivation from Settings.max_uploads_per_torrent.
5597
5598    #[test]
5599    fn initial_unchoke_slots_unlimited_returns_default_four() {
5600        assert_eq!(initial_unchoke_slots(-1), 4);
5601    }
5602
5603    #[test]
5604    fn initial_unchoke_slots_capped_returns_value() {
5605        assert_eq!(initial_unchoke_slots(1), 1);
5606        assert_eq!(initial_unchoke_slots(4), 4);
5607        assert_eq!(initial_unchoke_slots(16), 16);
5608    }
5609
5610    // -- Helpers --
5611
5612    /// Build a valid `TorrentMetaV1` from raw data with given piece length.
5613    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
5614        use serde::Serialize;
5615
5616        #[derive(Serialize)]
5617        struct Info<'a> {
5618            length: u64,
5619            name: &'a str,
5620            #[serde(rename = "piece length")]
5621            piece_length: u64,
5622            #[serde(with = "serde_bytes")]
5623            pieces: &'a [u8],
5624        }
5625
5626        #[derive(Serialize)]
5627        struct Torrent<'a> {
5628            info: Info<'a>,
5629        }
5630
5631        let mut pieces = Vec::new();
5632        let mut offset = 0;
5633        while offset < data.len() {
5634            let end = (offset + piece_length as usize).min(data.len());
5635            let hash = irontide_core::sha1(&data[offset..end]);
5636            pieces.extend_from_slice(hash.as_bytes());
5637            offset = end;
5638        }
5639
5640        let t = Torrent {
5641            info: Info {
5642                length: data.len() as u64,
5643                name: "test",
5644                piece_length,
5645                pieces: &pieces,
5646            },
5647        };
5648
5649        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5650        torrent_from_bytes(&bytes).unwrap()
5651    }
5652
5653    fn test_config() -> TorrentConfig {
5654        TorrentConfig {
5655            listen_port: 0, // random port
5656            max_peers: 200,
5657            target_request_queue: 5,
5658            download_dir: std::path::PathBuf::from("/tmp"),
5659            enable_dht: false,
5660            enable_pex: false,
5661            enable_fast: false,
5662            seed_ratio_limit: None,
5663            seed_time_limit_secs: None,
5664            inactive_seed_time_limit_secs: None,
5665            strict_end_game: true,
5666            upload_rate_limit: 0,
5667            download_rate_limit: 0,
5668            max_uploads_per_torrent: -1,
5669            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
5670            enable_utp: false,
5671            enable_web_seed: true,
5672            enable_holepunch: false,
5673            enable_bep40_eviction: true,
5674            max_web_seeds: 4,
5675            web_seed_retry_base_secs: 10,
5676            web_seed_retry_factor: 6,
5677            web_seed_retry_cap_secs: 3600,
5678            web_seed_max_failures: 10,
5679            super_seeding: false,
5680            upload_only_announce: true,
5681            hashing_threads: 2,
5682            sequential_download: false,
5683            initial_picker_threshold: 4,
5684            whole_pieces_threshold: 20,
5685            snub_timeout_secs: 15,
5686            readahead_pieces: 8,
5687            streaming_timeout_escalation: true,
5688            max_concurrent_stream_reads: 8,
5689            proxy: crate::proxy::ProxyConfig::default(),
5690            anonymous_mode: false,
5691            share_mode: false,
5692            enable_i2p: false,
5693            allow_i2p_mixed: false,
5694            ssl_listen_port: 0,
5695            seed_choking_algorithm: crate::choker::SeedChokingAlgorithm::FastestUpload,
5696            choking_algorithm: crate::choker::ChokingAlgorithm::FixedSlots,
5697            piece_extent_affinity: true,
5698            suggest_mode: false,
5699            max_suggest_pieces: 10,
5700            predictive_piece_announce_ms: 0,
5701            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
5702            auto_sequential: true,
5703            storage_mode: irontide_core::StorageMode::Auto,
5704            preallocate_mode: None,
5705            block_request_timeout_secs: 60,
5706            enable_lsd: false,
5707            force_proxy: false,
5708            steal_threshold_ratio: 10.0,
5709            steal_threshold_endgame: 3.0,
5710            peer_read_timeout_secs: 0,         // disabled in tests
5711            peer_write_timeout_secs: 0,        // disabled in tests
5712            data_contribution_timeout_secs: 0, // disabled in tests
5713            // v0.187.3 eviction tunables — defaults that match production.
5714            pass0_grace_secs: 60,
5715            proactive_evictions_per_minute_limit: 30,
5716            eviction_ban_duration_secs: 600,
5717            eviction_ban_set_cap: 1024,
5718            choke_rotation_max_evictions: 0, // disabled in tests
5719            max_concurrent_connects: 128,
5720            connect_soft_timeout: 3,
5721            dispatch_backlog_cap: 8,
5722            event_backlog_cap: 32,
5723            peer_writer_channel_cap: 1024,
5724            use_actor_dispatch: true,
5725            web_seed_progress_throttle_ms: 250,
5726            url_security: crate::url_guard::UrlSecurityConfig::default(),
5727            peer_connect_timeout: 2,
5728            peer_dscp: 0x08,
5729            initial_queue_depth: 128,
5730            max_request_queue_depth: 250,
5731            request_queue_time: 3.0,
5732            max_metadata_size: 4 * 1024 * 1024,
5733            max_message_size: 16 * 1024 * 1024,
5734            max_piece_length: 32 * 1024 * 1024,
5735            max_outstanding_requests: 500,
5736            max_in_flight_pieces: 20,
5737            use_block_stealing: true,
5738            steal_stale_piece_secs: 2,
5739            fixed_pipeline_depth: 128,
5740            lock_warn_threshold_ms: 0, // disabled in tests
5741            filesystem_direct_io: false,
5742            category: None,
5743            tags: Vec::new(),
5744        }
5745    }
5746
5747    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5748        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5749        Arc::new(MemoryStorage::new(lengths))
5750    }
5751
5752    fn make_seeded_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5753        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5754        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
5755        // Write data piece by piece
5756        let num_pieces = lengths.num_pieces();
5757        for p in 0..num_pieces {
5758            let piece_size = lengths.piece_size(p) as usize;
5759            let offset = lengths.piece_offset(p) as usize;
5760            let end = offset + piece_size;
5761            storage.write_chunk(p, 0, &data[offset..end]).unwrap();
5762        }
5763        storage
5764    }
5765
5766    fn test_alert_channel() -> (broadcast::Sender<Alert>, Arc<AtomicU32>) {
5767        let (tx, _) = broadcast::channel(64);
5768        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5769        (tx, mask)
5770    }
5771
5772    fn test_ban_manager() -> irontide_session_types::SharedBanManager {
5773        Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5774            crate::ban::BanConfig::default(),
5775        )))
5776    }
5777
5778    fn test_ip_filter() -> irontide_session_types::SharedIpFilter {
5779        Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()))
5780    }
5781
5782    fn test_disk_manager() -> (DiskManagerHandle, tokio::task::JoinHandle<()>) {
5783        DiskManagerHandle::new(crate::disk::DiskConfig::default())
5784    }
5785
5786    async fn test_register_disk(
5787        info_hash: Id20,
5788        storage: Arc<dyn TorrentStorage>,
5789    ) -> (DiskHandle, DiskManagerHandle, tokio::task::JoinHandle<()>) {
5790        let (dm, join) = test_disk_manager();
5791        let dh = dm.register_torrent(info_hash, storage).await;
5792        (dh, dm, join)
5793    }
5794
5795    /// M173 Lane B (B6): build a `DhtReceiver` pre-populated with `None`
5796    /// — what the test fixtures previously passed as `dht: None`.
5797    fn test_dht_rx() -> irontide_dht::DhtReceiver {
5798        // `&'static` storage so each call returns a fresh subscriber
5799        // without leaking the underlying broadcast.
5800        let bx = irontide_dht::DhtBroadcast::new(None);
5801        bx.subscribe()
5802    }
5803
5804    /// Handshake size constant.
5805    const HANDSHAKE_SIZE: usize = 68;
5806
5807    // ---- Test 1: Create from torrent ----
5808
5809    #[tokio::test]
5810    async fn create_from_torrent() {
5811        let data = vec![0xAB; 32768]; // 32 KiB
5812        let meta = make_test_torrent(&data, 16384); // 2 pieces
5813        let storage = make_storage(&data, 16384);
5814        let config = test_config();
5815
5816        let (atx, amask) = test_alert_channel();
5817        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5818        let handle = TorrentHandle::from_torrent(
5819            meta,
5820            irontide_core::TorrentVersion::V1Only,
5821            None,
5822            dh,
5823            dm,
5824            config,
5825            test_dht_rx(),
5826            test_dht_rx(),
5827            None,
5828            None,
5829            crate::slot_tuner::SlotTuner::disabled(4),
5830            atx,
5831            amask,
5832            None,
5833            None,
5834            test_ban_manager(),
5835            test_ip_filter(),
5836            Arc::new(Vec::new()),
5837            None,
5838            None,
5839            Arc::new(crate::transport::NetworkFactory::tokio()),
5840            None, // M96: hash_pool
5841            Arc::new(crate::stats::SessionCounters::new()),
5842        )
5843        .await
5844        .unwrap();
5845
5846        let stats = handle.stats().await.unwrap();
5847        assert_eq!(stats.state, TorrentState::Downloading);
5848        assert_eq!(stats.pieces_total, 2);
5849        assert_eq!(stats.pieces_have, 0);
5850        assert_eq!(stats.peers_connected, 0);
5851
5852        handle.shutdown().await.unwrap();
5853    }
5854
5855    // ---- M245 F1: atomic TakeResumeIfDirty / MarkResumeDirty ----
5856
5857    /// Build a started `TorrentHandle` over a 2-piece in-memory torrent.
5858    /// Returns the handle, the independently-derived expected piece-hash hex
5859    /// (v1 SHA-1, computed from `meta.info.pieces` BEFORE the meta is consumed —
5860    /// the parity oracle for L3), and the disk-manager join handle (kept alive
5861    /// by the caller — dropping it would abort the disk-manager task). Mirrors
5862    /// `create_from_torrent`'s construction so the tests drive a real actor.
5863    async fn started_test_handle() -> (TorrentHandle, Vec<String>, tokio::task::JoinHandle<()>) {
5864        let data = vec![0xAB; 32768]; // 32 KiB → 2 pieces at 16 KiB
5865        let meta = make_test_torrent(&data, 16384);
5866        let expected_hex: Vec<String> =
5867            meta.info.pieces.chunks_exact(20).map(hex::encode).collect();
5868        let storage = make_storage(&data, 16384);
5869        let config = test_config();
5870
5871        let (atx, amask) = test_alert_channel();
5872        let (dh, dm, dj) = test_register_disk(meta.info_hash, storage).await;
5873        let handle = TorrentHandle::from_torrent(
5874            meta,
5875            irontide_core::TorrentVersion::V1Only,
5876            None,
5877            dh,
5878            dm,
5879            config,
5880            test_dht_rx(),
5881            test_dht_rx(),
5882            None,
5883            None,
5884            crate::slot_tuner::SlotTuner::disabled(4),
5885            atx,
5886            amask,
5887            None,
5888            None,
5889            test_ban_manager(),
5890            test_ip_filter(),
5891            Arc::new(Vec::new()),
5892            None,
5893            None,
5894            Arc::new(crate::transport::NetworkFactory::tokio()),
5895            None,
5896            Arc::new(crate::stats::SessionCounters::new()),
5897        )
5898        .await
5899        .unwrap();
5900        (handle, expected_hex, dj)
5901    }
5902
5903    /// F1 atomicity: a dirty torrent yields resume data exactly ONCE — the take
5904    /// clears `need_save_resume` in the same actor turn, so the immediate second
5905    /// take sees a clean torrent and returns `None`. This is the property the
5906    /// pre-M245 `stats()`→`save_resume_data()`→`clear_save_resume_flag()`
5907    /// three-step could not guarantee (a dirty mark landing between the separate
5908    /// check and clear was lost).
5909    #[tokio::test]
5910    async fn take_resume_if_dirty_is_atomic_capture_and_clear() {
5911        let (handle, _expected_hex, _dj) = started_test_handle().await;
5912
5913        // SetTags is a synchronous, explicit `need_save_resume = true` with a
5914        // reply — a deterministic dirty trigger (no reliance on state-machine
5915        // side effects).
5916        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
5917
5918        let first = handle.take_resume_if_dirty().await.unwrap();
5919        assert!(first.is_some(), "dirty torrent must yield resume data");
5920
5921        let second = handle.take_resume_if_dirty().await.unwrap();
5922        assert!(
5923            second.is_none(),
5924            "flag was cleared atomically in the same take — no second capture"
5925        );
5926
5927        handle.shutdown().await.unwrap();
5928    }
5929
5930    /// F1 retry guarantee (RATIFIED D3-A): after a take has already cleared the
5931    /// flag, a simulated off-actor write failure re-dirties via
5932    /// `mark_resume_dirty`, and the NEXT take re-captures — the resume update is
5933    /// not lost. Without the re-dirty the torrent would stay clean and skip its
5934    /// retry until it next mutated, risking a stale `.resume` on disk.
5935    #[tokio::test]
5936    async fn mark_resume_dirty_restores_capture_after_write_failure() {
5937        let (handle, _expected_hex, _dj) = started_test_handle().await;
5938
5939        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
5940
5941        let captured = handle.take_resume_if_dirty().await.unwrap();
5942        assert!(captured.is_some(), "dirty torrent captured once");
5943
5944        // Clean now — confirm the take cleared it before we re-dirty.
5945        let between = handle.take_resume_if_dirty().await.unwrap();
5946        assert!(between.is_none(), "take cleared the flag");
5947
5948        // Simulate run_resume_save_jobs' write failure path re-arming the flag.
5949        handle.mark_resume_dirty().await.unwrap();
5950
5951        let recaptured = handle.take_resume_if_dirty().await.unwrap();
5952        assert!(
5953            recaptured.is_some(),
5954            "re-dirtied torrent must re-capture — no lost resume update"
5955        );
5956
5957        handle.shutdown().await.unwrap();
5958    }
5959
5960    /// L3 (M245): `get_piece_hashes` returns correctly hex-encoded v1 SHA-1
5961    /// piece hashes. Behaviour-parity guard for moving the `hex::encode` OFF the
5962    /// recv loop (the actor now returns raw bytes for the window; the handle
5963    /// method encodes) — the public `Vec<String>` output must stay byte-identical
5964    /// to encoding `meta.info.pieces` directly. Also pins the windowing
5965    /// semantics: `[offset, offset+limit)` clamped to the hash count.
5966    #[tokio::test]
5967    async fn get_piece_hashes_hex_parity_and_windowing() {
5968        let (handle, expected_hex, _dj) = started_test_handle().await;
5969        assert_eq!(expected_hex.len(), 2, "2-piece test torrent");
5970
5971        // Full range: output equals the independently-computed hex.
5972        let all = handle.get_piece_hashes(0, 1000).await.unwrap();
5973        assert_eq!(
5974            all, expected_hex,
5975            "hex output must match the raw piece hashes"
5976        );
5977
5978        // Window: offset 1, limit 1 → only the second hash.
5979        let windowed = handle.get_piece_hashes(1, 1).await.unwrap();
5980        assert_eq!(windowed, vec![expected_hex[1].clone()]);
5981
5982        // offset 0, limit 1 → only the first.
5983        let first = handle.get_piece_hashes(0, 1).await.unwrap();
5984        assert_eq!(first, vec![expected_hex[0].clone()]);
5985
5986        // Offset past the end → empty (clamped, no panic).
5987        let past = handle.get_piece_hashes(99, 5).await.unwrap();
5988        assert!(past.is_empty(), "offset past end yields empty");
5989
5990        // limit beyond end → clamped to what remains.
5991        let clamped = handle.get_piece_hashes(1, 1000).await.unwrap();
5992        assert_eq!(clamped, vec![expected_hex[1].clone()]);
5993
5994        handle.shutdown().await.unwrap();
5995    }
5996
5997    // ---- Test 2: Create from magnet ----
5998
5999    #[tokio::test]
6000    async fn create_from_magnet() {
6001        let magnet = Magnet {
6002            info_hashes: irontide_core::InfoHashes::v1_only(
6003                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
6004            ),
6005            display_name: Some("test".into()),
6006            trackers: vec![],
6007            peers: vec![],
6008            selected_files: None,
6009        };
6010        let config = test_config();
6011
6012        let (atx, amask) = test_alert_channel();
6013        let (dm, _dj) = test_disk_manager();
6014        let handle = TorrentHandle::from_magnet(
6015            magnet,
6016            dm,
6017            config,
6018            test_dht_rx(),
6019            test_dht_rx(),
6020            None,
6021            None,
6022            crate::slot_tuner::SlotTuner::disabled(4),
6023            atx,
6024            amask,
6025            None,
6026            None,
6027            test_ban_manager(),
6028            test_ip_filter(),
6029            Arc::new(Vec::new()),
6030            None,
6031            None,
6032            Arc::new(crate::transport::NetworkFactory::tokio()),
6033            None, // M96: hash_pool
6034            Arc::new(crate::stats::SessionCounters::new()),
6035        )
6036        .await
6037        .unwrap();
6038
6039        let stats = handle.stats().await.unwrap();
6040        assert_eq!(stats.state, TorrentState::FetchingMetadata);
6041        assert_eq!(stats.pieces_total, 0);
6042
6043        handle.shutdown().await.unwrap();
6044    }
6045
6046    // ---- Test 3: Add peers ----
6047
6048    #[tokio::test]
6049    async fn add_peers_increases_available() {
6050        let data = vec![0xAB; 32768];
6051        let meta = make_test_torrent(&data, 16384);
6052        let storage = make_storage(&data, 16384);
6053        let config = test_config();
6054
6055        let (atx, amask) = test_alert_channel();
6056        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6057        let handle = TorrentHandle::from_torrent(
6058            meta,
6059            irontide_core::TorrentVersion::V1Only,
6060            None,
6061            dh,
6062            dm,
6063            config,
6064            test_dht_rx(),
6065            test_dht_rx(),
6066            None,
6067            None,
6068            crate::slot_tuner::SlotTuner::disabled(4),
6069            atx,
6070            amask,
6071            None,
6072            None,
6073            test_ban_manager(),
6074            test_ip_filter(),
6075            Arc::new(Vec::new()),
6076            None,
6077            None,
6078            Arc::new(crate::transport::NetworkFactory::tokio()),
6079            None, // M96: hash_pool
6080            Arc::new(crate::stats::SessionCounters::new()),
6081        )
6082        .await
6083        .unwrap();
6084
6085        // Bind listeners so the connect attempts succeed and peers stay in connected state
6086        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6087        let addr1 = listener1.local_addr().unwrap();
6088        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6089        let addr2 = listener2.local_addr().unwrap();
6090
6091        handle
6092            .add_peers(vec![addr1, addr2], PeerSource::Tracker)
6093            .await
6094            .unwrap();
6095
6096        // Small delay for the actor to process
6097        tokio::time::sleep(Duration::from_millis(100)).await;
6098
6099        let stats = handle.stats().await.unwrap();
6100        // Peers may be available or already connecting (try_connect_peers fires immediately)
6101        assert!(
6102            stats.peers_available + stats.peers_connected >= 2,
6103            "expected at least 2 peers known, got available={}, connected={}",
6104            stats.peers_available,
6105            stats.peers_connected
6106        );
6107
6108        handle.shutdown().await.unwrap();
6109    }
6110
6111    // ---- Test 4: Stats reporting ----
6112
6113    #[tokio::test]
6114    async fn stats_reporting() {
6115        let data = vec![0xAB; 65536]; // 64 KiB
6116        let meta = make_test_torrent(&data, 16384); // 4 pieces
6117        let storage = make_storage(&data, 16384);
6118        let config = test_config();
6119
6120        let (atx, amask) = test_alert_channel();
6121        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6122        let handle = TorrentHandle::from_torrent(
6123            meta,
6124            irontide_core::TorrentVersion::V1Only,
6125            None,
6126            dh,
6127            dm,
6128            config,
6129            test_dht_rx(),
6130            test_dht_rx(),
6131            None,
6132            None,
6133            crate::slot_tuner::SlotTuner::disabled(4),
6134            atx,
6135            amask,
6136            None,
6137            None,
6138            test_ban_manager(),
6139            test_ip_filter(),
6140            Arc::new(Vec::new()),
6141            None,
6142            None,
6143            Arc::new(crate::transport::NetworkFactory::tokio()),
6144            None, // M96: hash_pool
6145            Arc::new(crate::stats::SessionCounters::new()),
6146        )
6147        .await
6148        .unwrap();
6149
6150        let stats = handle.stats().await.unwrap();
6151        assert_eq!(stats.state, TorrentState::Downloading);
6152        assert_eq!(stats.downloaded, 0);
6153        assert_eq!(stats.uploaded, 0);
6154        assert_eq!(stats.pieces_have, 0);
6155        assert_eq!(stats.pieces_total, 4);
6156        assert_eq!(stats.peers_connected, 0);
6157        assert_eq!(stats.peers_available, 0);
6158
6159        handle.shutdown().await.unwrap();
6160    }
6161
6162    // ---- Test 5: Private torrent disables DHT/PEX ----
6163
6164    #[tokio::test]
6165    async fn private_torrent_disables_dht_pex() {
6166        // Build a private torrent by embedding private=1 in the info dict
6167        use serde::Serialize;
6168
6169        #[derive(Serialize)]
6170        struct Info<'a> {
6171            length: u64,
6172            name: &'a str,
6173            #[serde(rename = "piece length")]
6174            piece_length: u64,
6175            #[serde(with = "serde_bytes")]
6176            pieces: &'a [u8],
6177            private: i64,
6178        }
6179
6180        #[derive(Serialize)]
6181        struct Torrent<'a> {
6182            info: Info<'a>,
6183        }
6184
6185        let data = vec![0xAB; 16384];
6186        let hash = irontide_core::sha1(&data);
6187        let mut pieces = Vec::new();
6188        pieces.extend_from_slice(hash.as_bytes());
6189
6190        let t = Torrent {
6191            info: Info {
6192                length: data.len() as u64,
6193                name: "private_test",
6194                piece_length: 16384,
6195                pieces: &pieces,
6196                private: 1,
6197            },
6198        };
6199
6200        let bytes = irontide_bencode::to_bytes(&t).unwrap();
6201        let meta = torrent_from_bytes(&bytes).unwrap();
6202        assert_eq!(meta.info.private, Some(1));
6203
6204        let storage = make_storage(&data, 16384);
6205        let mut config = test_config();
6206        config.enable_dht = true;
6207        config.enable_pex = true;
6208
6209        // The from_torrent constructor should disable DHT and PEX
6210        let (atx, amask) = test_alert_channel();
6211        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6212        let handle = TorrentHandle::from_torrent(
6213            meta,
6214            irontide_core::TorrentVersion::V1Only,
6215            None,
6216            dh,
6217            dm,
6218            config,
6219            test_dht_rx(),
6220            test_dht_rx(),
6221            None,
6222            None,
6223            crate::slot_tuner::SlotTuner::disabled(4),
6224            atx,
6225            amask,
6226            None,
6227            None,
6228            test_ban_manager(),
6229            test_ip_filter(),
6230            Arc::new(Vec::new()),
6231            None,
6232            None,
6233            Arc::new(crate::transport::NetworkFactory::tokio()),
6234            None, // M96: hash_pool
6235            Arc::new(crate::stats::SessionCounters::new()),
6236        )
6237        .await
6238        .unwrap();
6239
6240        // We can't directly inspect the actor's config, but we can verify
6241        // the torrent was created successfully. The real test is that PEX peers
6242        // would be ignored and DHT not used. For now verify the handle works.
6243        let stats = handle.stats().await.unwrap();
6244        assert_eq!(stats.state, TorrentState::Downloading);
6245
6246        handle.shutdown().await.unwrap();
6247    }
6248
6249    // ---- Test 6: Shutdown cleanup ----
6250
6251    #[tokio::test]
6252    async fn shutdown_cleanup() {
6253        let data = vec![0xAB; 16384];
6254        let meta = make_test_torrent(&data, 16384);
6255        let storage = make_storage(&data, 16384);
6256        let config = test_config();
6257
6258        let (atx, amask) = test_alert_channel();
6259        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6260        let handle = TorrentHandle::from_torrent(
6261            meta,
6262            irontide_core::TorrentVersion::V1Only,
6263            None,
6264            dh,
6265            dm,
6266            config,
6267            test_dht_rx(),
6268            test_dht_rx(),
6269            None,
6270            None,
6271            crate::slot_tuner::SlotTuner::disabled(4),
6272            atx,
6273            amask,
6274            None,
6275            None,
6276            test_ban_manager(),
6277            test_ip_filter(),
6278            Arc::new(Vec::new()),
6279            None,
6280            None,
6281            Arc::new(crate::transport::NetworkFactory::tokio()),
6282            None, // M96: hash_pool
6283            Arc::new(crate::stats::SessionCounters::new()),
6284        )
6285        .await
6286        .unwrap();
6287
6288        handle.shutdown().await.unwrap();
6289
6290        // After shutdown, stats should fail (channel closed)
6291        tokio::time::sleep(Duration::from_millis(50)).await;
6292        let result = handle.stats().await;
6293        assert!(result.is_err());
6294    }
6295
6296    // ---- Test 7: Duplicate add_peers ignored ----
6297
6298    #[tokio::test]
6299    async fn duplicate_peers_ignored() {
6300        let data = vec![0xAB; 16384];
6301        let meta = make_test_torrent(&data, 16384);
6302        let storage = make_storage(&data, 16384);
6303        let config = test_config();
6304
6305        let (atx, amask) = test_alert_channel();
6306        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6307        let handle = TorrentHandle::from_torrent(
6308            meta,
6309            irontide_core::TorrentVersion::V1Only,
6310            None,
6311            dh,
6312            dm,
6313            config,
6314            test_dht_rx(),
6315            test_dht_rx(),
6316            None,
6317            None,
6318            crate::slot_tuner::SlotTuner::disabled(4),
6319            atx,
6320            amask,
6321            None,
6322            None,
6323            test_ban_manager(),
6324            test_ip_filter(),
6325            Arc::new(Vec::new()),
6326            None,
6327            None,
6328            Arc::new(crate::transport::NetworkFactory::tokio()),
6329            None, // M96: hash_pool
6330            Arc::new(crate::stats::SessionCounters::new()),
6331        )
6332        .await
6333        .unwrap();
6334
6335        // Bind a listener so the connection succeeds and the peer stays connected
6336        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6337        let addr = listener.local_addr().unwrap();
6338        handle
6339            .add_peers(vec![addr, addr, addr], PeerSource::Tracker)
6340            .await
6341            .unwrap();
6342
6343        tokio::time::sleep(Duration::from_millis(100)).await;
6344        let stats = handle.stats().await.unwrap();
6345        // Only one unique peer should be known (available or connecting)
6346        assert!(
6347            stats.peers_available + stats.peers_connected <= 1,
6348            "expected at most 1 unique peer, got available={}, connected={}",
6349            stats.peers_available,
6350            stats.peers_connected
6351        );
6352
6353        handle.shutdown().await.unwrap();
6354    }
6355
6356    // ---- Test 8: Multiple handles (Clone) share same actor ----
6357
6358    #[tokio::test]
6359    async fn cloned_handle_shares_actor() {
6360        let data = vec![0xAB; 16384];
6361        let meta = make_test_torrent(&data, 16384);
6362        let storage = make_storage(&data, 16384);
6363        let config = test_config();
6364
6365        let (atx, amask) = test_alert_channel();
6366        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6367        let handle = TorrentHandle::from_torrent(
6368            meta,
6369            irontide_core::TorrentVersion::V1Only,
6370            None,
6371            dh,
6372            dm,
6373            config,
6374            test_dht_rx(),
6375            test_dht_rx(),
6376            None,
6377            None,
6378            crate::slot_tuner::SlotTuner::disabled(4),
6379            atx,
6380            amask,
6381            None,
6382            None,
6383            test_ban_manager(),
6384            test_ip_filter(),
6385            Arc::new(Vec::new()),
6386            None,
6387            None,
6388            Arc::new(crate::transport::NetworkFactory::tokio()),
6389            None, // M96: hash_pool
6390            Arc::new(crate::stats::SessionCounters::new()),
6391        )
6392        .await
6393        .unwrap();
6394        let handle2 = handle.clone();
6395
6396        // Bind a listener so the connection succeeds and the peer stays connected
6397        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6398        let peer_addr = listener.local_addr().unwrap();
6399
6400        // Add peers through one handle
6401        handle
6402            .add_peers(vec![peer_addr], PeerSource::Tracker)
6403            .await
6404            .unwrap();
6405
6406        tokio::time::sleep(Duration::from_millis(100)).await;
6407
6408        // Read stats through the other — peer may be available or connecting
6409        let stats = handle2.stats().await.unwrap();
6410        assert!(
6411            stats.peers_available + stats.peers_connected >= 1,
6412            "expected at least 1 peer known, got available={}, connected={}",
6413            stats.peers_available,
6414            stats.peers_connected
6415        );
6416
6417        handle.shutdown().await.unwrap();
6418    }
6419
6420    // ---- Test 9: Peer connection and disconnect via listener ----
6421
6422    #[tokio::test]
6423    async fn peer_connect_and_disconnect_via_listener() {
6424        let data = vec![0xAB; 16384];
6425        let meta = make_test_torrent(&data, 16384);
6426        let info_hash = meta.info_hash;
6427        let storage = make_storage(&data, 16384);
6428
6429        // Bind a listener on a specific port so we can connect to it
6430        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6431        let listen_addr = listener.local_addr().unwrap();
6432
6433        let config = TorrentConfig {
6434            listen_port: listen_addr.port(),
6435            ..test_config()
6436        };
6437
6438        // Drop the pre-bound listener before from_torrent binds
6439        drop(listener);
6440
6441        let (atx, amask) = test_alert_channel();
6442        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6443        let handle = TorrentHandle::from_torrent(
6444            meta,
6445            irontide_core::TorrentVersion::V1Only,
6446            None,
6447            dh,
6448            dm,
6449            config,
6450            test_dht_rx(),
6451            test_dht_rx(),
6452            None,
6453            None,
6454            crate::slot_tuner::SlotTuner::disabled(4),
6455            atx,
6456            amask,
6457            None,
6458            None,
6459            test_ban_manager(),
6460            test_ip_filter(),
6461            Arc::new(Vec::new()),
6462            None,
6463            None,
6464            Arc::new(crate::transport::NetworkFactory::tokio()),
6465            None, // M96: hash_pool
6466            Arc::new(crate::stats::SessionCounters::new()),
6467        )
6468        .await
6469        .unwrap();
6470
6471        // Give the actor time to start
6472        tokio::time::sleep(Duration::from_millis(50)).await;
6473
6474        // Connect a mock peer
6475        let mut stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6476
6477        // Perform handshake
6478        let remote_id = Id20::from_hex("1111111111111111111111111111111111111111").unwrap();
6479        let remote_hs = Handshake::new(info_hash, remote_id);
6480        stream.write_all(&remote_hs.to_bytes()).await.unwrap();
6481        stream.flush().await.unwrap();
6482
6483        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6484        stream.read_exact(&mut hs_buf).await.unwrap();
6485        let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
6486        assert_eq!(their_hs.info_hash, info_hash);
6487
6488        // Give time for peer to be registered
6489        tokio::time::sleep(Duration::from_millis(100)).await;
6490
6491        let stats = handle.stats().await.unwrap();
6492        assert_eq!(stats.peers_connected, 1);
6493
6494        // Drop the connection
6495        drop(stream);
6496
6497        // Wait for disconnect event
6498        tokio::time::sleep(Duration::from_millis(200)).await;
6499
6500        let stats = handle.stats().await.unwrap();
6501        assert_eq!(stats.peers_connected, 0);
6502
6503        handle.shutdown().await.unwrap();
6504    }
6505
6506    // ---- Test 10: Piece download and verification via injected events ----
6507    //
6508    // We test the full flow: connect a mock peer that sends bitfield, unchoke,
6509    // then responds to requests with correct piece data.
6510
6511    #[tokio::test]
6512    async fn piece_download_and_verify() {
6513        // Create a 1-piece torrent with 16384 bytes (exactly one chunk)
6514        let data = vec![0xCDu8; 16384];
6515        let meta = make_test_torrent(&data, 16384);
6516        let info_hash = meta.info_hash;
6517        let storage = make_storage(&data, 16384);
6518
6519        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6520        let listen_addr = listener.local_addr().unwrap();
6521        drop(listener);
6522
6523        let config = TorrentConfig {
6524            listen_port: listen_addr.port(),
6525            ..test_config()
6526        };
6527
6528        let (atx, amask) = test_alert_channel();
6529        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6530        let handle = TorrentHandle::from_torrent(
6531            meta,
6532            irontide_core::TorrentVersion::V1Only,
6533            None,
6534            dh,
6535            dm,
6536            config,
6537            test_dht_rx(),
6538            test_dht_rx(),
6539            None,
6540            None,
6541            crate::slot_tuner::SlotTuner::disabled(4),
6542            atx,
6543            amask,
6544            None,
6545            None,
6546            test_ban_manager(),
6547            test_ip_filter(),
6548            Arc::new(Vec::new()),
6549            None,
6550            None,
6551            Arc::new(crate::transport::NetworkFactory::tokio()),
6552            None, // M96: hash_pool
6553            Arc::new(crate::stats::SessionCounters::new()),
6554        )
6555        .await
6556        .unwrap();
6557
6558        tokio::time::sleep(Duration::from_millis(50)).await;
6559
6560        // Connect mock peer
6561        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6562        let remote_id = Id20::from_hex("2222222222222222222222222222222222222222").unwrap();
6563
6564        // Run mock seeder in a task
6565        let mock_data = data.clone();
6566        let mock_task = tokio::spawn(async move {
6567            let (reader, writer) = tokio::io::split(stream);
6568            let mut reader = reader;
6569            let mut writer = writer;
6570
6571            // Handshake
6572            let hs = Handshake::new(info_hash, remote_id);
6573            writer.write_all(&hs.to_bytes()).await.unwrap();
6574            writer.flush().await.unwrap();
6575
6576            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6577            reader.read_exact(&mut hs_buf).await.unwrap();
6578
6579            // Switch to framed
6580            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6581            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6582
6583            // Read ext handshake from the torrent actor's peer
6584            let _msg = framed_read.next().await;
6585
6586            // Send ext handshake back
6587            let ext_hs = ExtHandshake::new();
6588            let payload = ext_hs.to_bytes().unwrap();
6589            framed_write
6590                .send(Message::Extended { ext_id: 0, payload })
6591                .await
6592                .unwrap();
6593
6594            // Send bitfield (all pieces = piece 0 set)
6595            let mut bf = Bitfield::new(1);
6596            bf.set(0);
6597            framed_write
6598                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6599                .await
6600                .unwrap();
6601
6602            // Send Unchoke
6603            framed_write.send(Message::Unchoke).await.unwrap();
6604
6605            // Wait for requests and respond with piece data
6606            while let Some(Ok(msg)) = framed_read.next().await {
6607                if let Message::Request {
6608                    index,
6609                    begin,
6610                    length,
6611                } = msg
6612                {
6613                    let start = begin as usize;
6614                    let end = start + length as usize;
6615                    let piece_data = &mock_data[start..end];
6616                    framed_write
6617                        .send(Message::Piece {
6618                            index,
6619                            begin,
6620                            data_0: Bytes::copy_from_slice(piece_data),
6621                            data_1: Bytes::new(),
6622                        })
6623                        .await
6624                        .unwrap();
6625                }
6626            }
6627        });
6628
6629        // Wait for the download to complete
6630        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6631        loop {
6632            tokio::time::sleep(Duration::from_millis(100)).await;
6633            let stats = handle.stats().await.unwrap();
6634            if stats.state == TorrentState::Seeding {
6635                assert_eq!(stats.pieces_have, 1);
6636                assert_eq!(stats.pieces_total, 1);
6637                break;
6638            }
6639            if tokio::time::Instant::now() > deadline {
6640                let stats = handle.stats().await.unwrap();
6641                panic!(
6642                    "download did not complete within 5s, state={:?}, have={}/{}",
6643                    stats.state, stats.pieces_have, stats.pieces_total
6644                );
6645            }
6646        }
6647
6648        handle.shutdown().await.unwrap();
6649        mock_task.abort();
6650    }
6651
6652    // ---- Test 11: Failed piece verification re-requests ----
6653
6654    #[tokio::test]
6655    async fn failed_piece_verification() {
6656        // Create a 1-piece torrent
6657        let data = vec![0xEEu8; 16384];
6658        let meta = make_test_torrent(&data, 16384);
6659        let info_hash = meta.info_hash;
6660        let storage = make_storage(&data, 16384);
6661
6662        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6663        let listen_addr = listener.local_addr().unwrap();
6664        drop(listener);
6665
6666        let config = TorrentConfig {
6667            listen_port: listen_addr.port(),
6668            ..test_config()
6669        };
6670
6671        let (atx, amask) = test_alert_channel();
6672        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6673        let handle = TorrentHandle::from_torrent(
6674            meta,
6675            irontide_core::TorrentVersion::V1Only,
6676            None,
6677            dh,
6678            dm,
6679            config,
6680            test_dht_rx(),
6681            test_dht_rx(),
6682            None,
6683            None,
6684            crate::slot_tuner::SlotTuner::disabled(4),
6685            atx,
6686            amask,
6687            None,
6688            None,
6689            test_ban_manager(),
6690            test_ip_filter(),
6691            Arc::new(Vec::new()),
6692            None,
6693            None,
6694            Arc::new(crate::transport::NetworkFactory::tokio()),
6695            None, // M96: hash_pool
6696            Arc::new(crate::stats::SessionCounters::new()),
6697        )
6698        .await
6699        .unwrap();
6700
6701        tokio::time::sleep(Duration::from_millis(50)).await;
6702
6703        // Connect mock peer that first sends bad data, then correct data
6704        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6705        let remote_id = Id20::from_hex("3333333333333333333333333333333333333333").unwrap();
6706
6707        let correct_data = data.clone();
6708        let mock_task = tokio::spawn(async move {
6709            let (reader, writer) = tokio::io::split(stream);
6710
6711            // Handshake
6712            let mut writer = writer;
6713            let mut reader = reader;
6714            let hs = Handshake::new(info_hash, remote_id);
6715            writer.write_all(&hs.to_bytes()).await.unwrap();
6716            writer.flush().await.unwrap();
6717
6718            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6719            reader.read_exact(&mut hs_buf).await.unwrap();
6720
6721            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6722            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6723
6724            // Read ext handshake
6725            let _msg = framed_read.next().await;
6726
6727            // Send ext handshake
6728            let ext_hs = ExtHandshake::new();
6729            let payload = ext_hs.to_bytes().unwrap();
6730            framed_write
6731                .send(Message::Extended { ext_id: 0, payload })
6732                .await
6733                .unwrap();
6734
6735            // Bitfield: have piece 0
6736            let mut bf = Bitfield::new(1);
6737            bf.set(0);
6738            framed_write
6739                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6740                .await
6741                .unwrap();
6742
6743            // Unchoke
6744            framed_write.send(Message::Unchoke).await.unwrap();
6745
6746            let mut request_count = 0u32;
6747            while let Some(Ok(msg)) = framed_read.next().await {
6748                if let Message::Request {
6749                    index,
6750                    begin,
6751                    length,
6752                } = msg
6753                {
6754                    request_count += 1;
6755                    let piece_data = if request_count <= 1 {
6756                        // First request: send bad data
6757                        vec![0xFF; length as usize]
6758                    } else {
6759                        // Subsequent: send correct data
6760                        let start = begin as usize;
6761                        let end = start + length as usize;
6762                        correct_data[start..end].to_vec()
6763                    };
6764                    framed_write
6765                        .send(Message::Piece {
6766                            index,
6767                            begin,
6768                            data_0: Bytes::from(piece_data),
6769                            data_1: Bytes::new(),
6770                        })
6771                        .await
6772                        .unwrap();
6773                }
6774            }
6775        });
6776
6777        // Wait for completion (should eventually succeed after retry)
6778        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6779        loop {
6780            tokio::time::sleep(Duration::from_millis(100)).await;
6781            let stats = handle.stats().await.unwrap();
6782            if stats.state == TorrentState::Seeding {
6783                assert_eq!(stats.pieces_have, 1);
6784                break;
6785            }
6786            if tokio::time::Instant::now() > deadline {
6787                let stats = handle.stats().await.unwrap();
6788                panic!(
6789                    "download did not complete after retry within 5s, state={:?}, have={}",
6790                    stats.state, stats.pieces_have,
6791                );
6792            }
6793        }
6794
6795        handle.shutdown().await.unwrap();
6796        mock_task.abort();
6797    }
6798
6799    // ---- Test 12: Complete state transitions after all pieces ----
6800
6801    #[tokio::test]
6802    async fn complete_transitions_state() {
6803        // 2-piece torrent, each 16384 bytes (one chunk each)
6804        let data = vec![0xBBu8; 32768];
6805        let meta = make_test_torrent(&data, 16384);
6806        let info_hash = meta.info_hash;
6807        let storage = make_storage(&data, 16384);
6808
6809        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6810        let listen_addr = listener.local_addr().unwrap();
6811        drop(listener);
6812
6813        let config = TorrentConfig {
6814            listen_port: listen_addr.port(),
6815            ..test_config()
6816        };
6817
6818        let (atx, amask) = test_alert_channel();
6819        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6820        let handle = TorrentHandle::from_torrent(
6821            meta,
6822            irontide_core::TorrentVersion::V1Only,
6823            None,
6824            dh,
6825            dm,
6826            config,
6827            test_dht_rx(),
6828            test_dht_rx(),
6829            None,
6830            None,
6831            crate::slot_tuner::SlotTuner::disabled(4),
6832            atx,
6833            amask,
6834            None,
6835            None,
6836            test_ban_manager(),
6837            test_ip_filter(),
6838            Arc::new(Vec::new()),
6839            None,
6840            None,
6841            Arc::new(crate::transport::NetworkFactory::tokio()),
6842            None, // M96: hash_pool
6843            Arc::new(crate::stats::SessionCounters::new()),
6844        )
6845        .await
6846        .unwrap();
6847
6848        tokio::time::sleep(Duration::from_millis(50)).await;
6849
6850        // Mock seeder with all 2 pieces
6851        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6852        let remote_id = Id20::from_hex("4444444444444444444444444444444444444444").unwrap();
6853
6854        let mock_data = data.clone();
6855        let mock_task = tokio::spawn(async move {
6856            let (reader, writer) = tokio::io::split(stream);
6857            let mut writer = writer;
6858            let mut reader = reader;
6859
6860            let hs = Handshake::new(info_hash, remote_id);
6861            writer.write_all(&hs.to_bytes()).await.unwrap();
6862            writer.flush().await.unwrap();
6863
6864            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6865            reader.read_exact(&mut hs_buf).await.unwrap();
6866
6867            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6868            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6869
6870            // Read ext handshake
6871            let _msg = framed_read.next().await;
6872
6873            // Send ext handshake
6874            let ext_hs = ExtHandshake::new();
6875            let payload = ext_hs.to_bytes().unwrap();
6876            framed_write
6877                .send(Message::Extended { ext_id: 0, payload })
6878                .await
6879                .unwrap();
6880
6881            // Bitfield: have both pieces
6882            let mut bf = Bitfield::new(2);
6883            bf.set(0);
6884            bf.set(1);
6885            framed_write
6886                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6887                .await
6888                .unwrap();
6889
6890            framed_write.send(Message::Unchoke).await.unwrap();
6891
6892            while let Some(Ok(msg)) = framed_read.next().await {
6893                if let Message::Request {
6894                    index,
6895                    begin,
6896                    length,
6897                } = msg
6898                {
6899                    let abs_start = (index as usize * 16384) + begin as usize;
6900                    let abs_end = abs_start + length as usize;
6901                    let piece_data = &mock_data[abs_start..abs_end];
6902                    framed_write
6903                        .send(Message::Piece {
6904                            index,
6905                            begin,
6906                            data_0: Bytes::copy_from_slice(piece_data),
6907                            data_1: Bytes::new(),
6908                        })
6909                        .await
6910                        .unwrap();
6911                }
6912            }
6913        });
6914
6915        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6916        loop {
6917            tokio::time::sleep(Duration::from_millis(100)).await;
6918            let stats = handle.stats().await.unwrap();
6919            if stats.state == TorrentState::Seeding {
6920                assert_eq!(stats.pieces_have, 2);
6921                assert_eq!(stats.pieces_total, 2);
6922                break;
6923            }
6924            if tokio::time::Instant::now() > deadline {
6925                let stats = handle.stats().await.unwrap();
6926                panic!(
6927                    "expected Complete, got {:?}, have={}/{}",
6928                    stats.state, stats.pieces_have, stats.pieces_total
6929                );
6930            }
6931        }
6932
6933        handle.shutdown().await.unwrap();
6934        mock_task.abort();
6935    }
6936
6937    // ---- Test 13: Multiple pieces with multi-chunk pieces ----
6938
6939    #[tokio::test]
6940    async fn multi_chunk_piece_download() {
6941        // 1 piece of 32768 bytes = 2 chunks of 16384 each
6942        let data = vec![0xAAu8; 32768];
6943        let meta = make_test_torrent(&data, 32768);
6944        let info_hash = meta.info_hash;
6945        let storage = make_storage(&data, 32768);
6946
6947        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6948        let listen_addr = listener.local_addr().unwrap();
6949        drop(listener);
6950
6951        let config = TorrentConfig {
6952            listen_port: listen_addr.port(),
6953            ..test_config()
6954        };
6955
6956        let (atx, amask) = test_alert_channel();
6957        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6958        let handle = TorrentHandle::from_torrent(
6959            meta,
6960            irontide_core::TorrentVersion::V1Only,
6961            None,
6962            dh,
6963            dm,
6964            config,
6965            test_dht_rx(),
6966            test_dht_rx(),
6967            None,
6968            None,
6969            crate::slot_tuner::SlotTuner::disabled(4),
6970            atx,
6971            amask,
6972            None,
6973            None,
6974            test_ban_manager(),
6975            test_ip_filter(),
6976            Arc::new(Vec::new()),
6977            None,
6978            None,
6979            Arc::new(crate::transport::NetworkFactory::tokio()),
6980            None, // M96: hash_pool
6981            Arc::new(crate::stats::SessionCounters::new()),
6982        )
6983        .await
6984        .unwrap();
6985
6986        tokio::time::sleep(Duration::from_millis(50)).await;
6987
6988        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6989        let remote_id = Id20::from_hex("5555555555555555555555555555555555555555").unwrap();
6990
6991        let mock_data = data.clone();
6992        let mock_task = tokio::spawn(async move {
6993            let (reader, writer) = tokio::io::split(stream);
6994            let mut writer = writer;
6995            let mut reader = reader;
6996
6997            let hs = Handshake::new(info_hash, remote_id);
6998            writer.write_all(&hs.to_bytes()).await.unwrap();
6999            writer.flush().await.unwrap();
7000
7001            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7002            reader.read_exact(&mut hs_buf).await.unwrap();
7003
7004            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7005            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7006
7007            let _msg = framed_read.next().await;
7008
7009            let ext_hs = ExtHandshake::new();
7010            let payload = ext_hs.to_bytes().unwrap();
7011            framed_write
7012                .send(Message::Extended { ext_id: 0, payload })
7013                .await
7014                .unwrap();
7015
7016            let mut bf = Bitfield::new(1);
7017            bf.set(0);
7018            framed_write
7019                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7020                .await
7021                .unwrap();
7022
7023            framed_write.send(Message::Unchoke).await.unwrap();
7024
7025            while let Some(Ok(msg)) = framed_read.next().await {
7026                if let Message::Request {
7027                    index: _,
7028                    begin,
7029                    length,
7030                } = msg
7031                {
7032                    let start = begin as usize;
7033                    let end = start + length as usize;
7034                    framed_write
7035                        .send(Message::Piece {
7036                            index: 0,
7037                            begin,
7038                            data_0: Bytes::copy_from_slice(&mock_data[start..end]),
7039                            data_1: Bytes::new(),
7040                        })
7041                        .await
7042                        .unwrap();
7043                }
7044            }
7045        });
7046
7047        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7048        loop {
7049            tokio::time::sleep(Duration::from_millis(100)).await;
7050            let stats = handle.stats().await.unwrap();
7051            if stats.state == TorrentState::Seeding {
7052                assert_eq!(stats.pieces_have, 1);
7053                break;
7054            }
7055            assert!(
7056                tokio::time::Instant::now() <= deadline,
7057                "multi-chunk download did not complete within 5s"
7058            );
7059        }
7060
7061        handle.shutdown().await.unwrap();
7062        mock_task.abort();
7063    }
7064
7065    // ---- Test 14: Seeder/Leecher integration with two actors ----
7066
7067    #[tokio::test]
7068    async fn seeder_leecher_integration() {
7069        // Seeder has all data, leecher has none. Connect them via TCP.
7070        let data = vec![0xDDu8; 32768]; // 32 KiB, 2 pieces of 16384
7071        let piece_length = 16384u64;
7072        let meta = make_test_torrent(&data, piece_length);
7073        let info_hash = meta.info_hash;
7074
7075        // Seeder: storage pre-filled
7076        let seeder_storage = make_seeded_storage(&data, piece_length);
7077
7078        // For the seeder, we need a from_torrent variant that starts in Complete state
7079        // but still serves pieces. Since our actor starts in Downloading, the seeder
7080        // will just be a mock that accepts and serves.
7081
7082        // Use a mock seeder approach instead (manual protocol handling):
7083        let seeder_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7084        let seeder_addr = seeder_listener.local_addr().unwrap();
7085
7086        let seeder_task = tokio::spawn(async move {
7087            let (stream, _addr) = seeder_listener.accept().await.unwrap();
7088            let (reader, writer) = tokio::io::split(stream);
7089            let mut writer = writer;
7090            let mut reader = reader;
7091
7092            // Handshake
7093            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7094            reader.read_exact(&mut hs_buf).await.unwrap();
7095            let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
7096            assert_eq!(their_hs.info_hash, info_hash);
7097
7098            let hs = Handshake::new(info_hash, PeerId::generate().0);
7099            writer.write_all(&hs.to_bytes()).await.unwrap();
7100            writer.flush().await.unwrap();
7101
7102            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7103            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7104
7105            // Read ext handshake
7106            let _msg = framed_read.next().await;
7107
7108            // Send ext handshake
7109            let ext_hs = ExtHandshake::new();
7110            let payload = ext_hs.to_bytes().unwrap();
7111            framed_write
7112                .send(Message::Extended { ext_id: 0, payload })
7113                .await
7114                .unwrap();
7115
7116            // Send bitfield (all pieces)
7117            let mut bf = Bitfield::new(2);
7118            bf.set(0);
7119            bf.set(1);
7120            framed_write
7121                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7122                .await
7123                .unwrap();
7124
7125            // Unchoke
7126            framed_write.send(Message::Unchoke).await.unwrap();
7127
7128            // Serve requests
7129            while let Some(Ok(msg)) = framed_read.next().await {
7130                if let Message::Request {
7131                    index,
7132                    begin,
7133                    length,
7134                } = msg
7135                {
7136                    let piece_data = seeder_storage.read_chunk(index, begin, length).unwrap();
7137                    framed_write
7138                        .send(Message::Piece {
7139                            index,
7140                            begin,
7141                            data_0: Bytes::from(piece_data),
7142                            data_1: Bytes::new(),
7143                        })
7144                        .await
7145                        .unwrap();
7146                }
7147            }
7148        });
7149
7150        // Leecher: empty storage
7151        let leecher_storage = make_storage(&data, piece_length);
7152        let leecher_meta = make_test_torrent(&data, piece_length);
7153
7154        let leecher_config = test_config();
7155        let (latx, lamask) = test_alert_channel();
7156        let (ldh, ldm, _ldj) = test_register_disk(leecher_meta.info_hash, leecher_storage).await;
7157        let leecher = TorrentHandle::from_torrent(
7158            leecher_meta,
7159            irontide_core::TorrentVersion::V1Only,
7160            None,
7161            ldh,
7162            ldm,
7163            leecher_config,
7164            test_dht_rx(),
7165            test_dht_rx(),
7166            None,
7167            None,
7168            crate::slot_tuner::SlotTuner::disabled(4),
7169            latx,
7170            lamask,
7171            None,
7172            None,
7173            test_ban_manager(),
7174            test_ip_filter(),
7175            Arc::new(Vec::new()),
7176            None,
7177            None,
7178            Arc::new(crate::transport::NetworkFactory::tokio()),
7179            None, // M96: hash_pool
7180            Arc::new(crate::stats::SessionCounters::new()),
7181        )
7182        .await
7183        .unwrap();
7184
7185        // Add seeder as a peer
7186        leecher
7187            .add_peers(vec![seeder_addr], PeerSource::Tracker)
7188            .await
7189            .unwrap();
7190
7191        // Give the connect interval time to fire (it ticks every 5s).
7192        // The actor's try_connect_peers runs on the timer, and also immediately
7193        // when peers are added via AddPeers command. Wait up to 10 seconds.
7194        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
7195        loop {
7196            tokio::time::sleep(Duration::from_millis(200)).await;
7197            let stats = leecher.stats().await.unwrap();
7198            if stats.state == TorrentState::Seeding {
7199                assert_eq!(stats.pieces_have, 2);
7200                assert_eq!(stats.pieces_total, 2);
7201                break;
7202            }
7203            if tokio::time::Instant::now() > deadline {
7204                let stats = leecher.stats().await.unwrap();
7205                panic!(
7206                    "seeder/leecher: leecher did not complete, state={:?}, have={}/{}, connected={}, available={}",
7207                    stats.state,
7208                    stats.pieces_have,
7209                    stats.pieces_total,
7210                    stats.peers_connected,
7211                    stats.peers_available,
7212                );
7213            }
7214        }
7215
7216        leecher.shutdown().await.unwrap();
7217        seeder_task.abort();
7218    }
7219
7220    // ---- Test 15: Magnet stats ----
7221
7222    #[tokio::test]
7223    async fn magnet_initial_stats() {
7224        let magnet = Magnet {
7225            info_hashes: irontide_core::InfoHashes::v1_only(
7226                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
7227            ),
7228            display_name: Some("magnet test".into()),
7229            trackers: vec![],
7230            peers: vec![],
7231            selected_files: None,
7232        };
7233
7234        let (atx, amask) = test_alert_channel();
7235        let (dm, _dj) = test_disk_manager();
7236        let handle = TorrentHandle::from_magnet(
7237            magnet,
7238            dm,
7239            test_config(),
7240            test_dht_rx(),
7241            test_dht_rx(),
7242            None,
7243            None,
7244            crate::slot_tuner::SlotTuner::disabled(4),
7245            atx,
7246            amask,
7247            None,
7248            None,
7249            test_ban_manager(),
7250            test_ip_filter(),
7251            Arc::new(Vec::new()),
7252            None,
7253            None,
7254            Arc::new(crate::transport::NetworkFactory::tokio()),
7255            None, // M96: hash_pool
7256            Arc::new(crate::stats::SessionCounters::new()),
7257        )
7258        .await
7259        .unwrap();
7260
7261        let stats = handle.stats().await.unwrap();
7262        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7263        assert_eq!(stats.pieces_total, 0);
7264        assert_eq!(stats.pieces_have, 0);
7265        assert_eq!(stats.downloaded, 0);
7266        assert_eq!(stats.uploaded, 0);
7267        assert_eq!(stats.peers_connected, 0);
7268        assert_eq!(stats.peers_available, 0);
7269
7270        handle.shutdown().await.unwrap();
7271    }
7272
7273    // ---- Test 16: Tracker manager is populated from torrent metadata ----
7274
7275    #[tokio::test]
7276    async fn tracker_populated_from_metadata() {
7277        use serde::Serialize;
7278
7279        #[derive(Serialize)]
7280        struct Info<'a> {
7281            length: u64,
7282            name: &'a str,
7283            #[serde(rename = "piece length")]
7284            piece_length: u64,
7285            #[serde(with = "serde_bytes")]
7286            pieces: &'a [u8],
7287        }
7288
7289        #[derive(Serialize)]
7290        struct Torrent<'a> {
7291            announce: &'a str,
7292            info: Info<'a>,
7293        }
7294
7295        let data = vec![0xAB; 16384];
7296        let hash = irontide_core::sha1(&data);
7297        let mut pieces = Vec::new();
7298        pieces.extend_from_slice(hash.as_bytes());
7299
7300        let t = Torrent {
7301            announce: "http://tracker.example.com:8080/announce",
7302            info: Info {
7303                length: data.len() as u64,
7304                name: "test",
7305                piece_length: 16384,
7306                pieces: &pieces,
7307            },
7308        };
7309
7310        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7311        let meta = torrent_from_bytes(&bytes).unwrap();
7312        assert!(meta.announce.is_some());
7313
7314        let storage = make_storage(&data, 16384);
7315        let config = test_config();
7316
7317        // The torrent should start and announce to tracker (which will fail since
7318        // the tracker doesn't exist, but that's fine — failures are non-fatal).
7319        let (atx, amask) = test_alert_channel();
7320        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7321        let handle = TorrentHandle::from_torrent(
7322            meta,
7323            irontide_core::TorrentVersion::V1Only,
7324            None,
7325            dh,
7326            dm,
7327            config,
7328            test_dht_rx(),
7329            test_dht_rx(),
7330            None,
7331            None,
7332            crate::slot_tuner::SlotTuner::disabled(4),
7333            atx,
7334            amask,
7335            None,
7336            None,
7337            test_ban_manager(),
7338            test_ip_filter(),
7339            Arc::new(Vec::new()),
7340            None,
7341            None,
7342            Arc::new(crate::transport::NetworkFactory::tokio()),
7343            None, // M96: hash_pool
7344            Arc::new(crate::stats::SessionCounters::new()),
7345        )
7346        .await
7347        .unwrap();
7348
7349        let stats = handle.stats().await.unwrap();
7350        assert_eq!(stats.state, TorrentState::Downloading);
7351
7352        handle.shutdown().await.unwrap();
7353    }
7354
7355    // ---- Test 17: Private torrent with DHT=None works ----
7356
7357    #[tokio::test]
7358    async fn private_torrent_no_dht_field() {
7359        use serde::Serialize;
7360
7361        #[derive(Serialize)]
7362        struct Info<'a> {
7363            length: u64,
7364            name: &'a str,
7365            #[serde(rename = "piece length")]
7366            piece_length: u64,
7367            #[serde(with = "serde_bytes")]
7368            pieces: &'a [u8],
7369            private: i64,
7370        }
7371
7372        #[derive(Serialize)]
7373        struct Torrent<'a> {
7374            announce: &'a str,
7375            info: Info<'a>,
7376        }
7377
7378        let data = vec![0xAB; 16384];
7379        let hash = irontide_core::sha1(&data);
7380        let mut pieces = Vec::new();
7381        pieces.extend_from_slice(hash.as_bytes());
7382
7383        let t = Torrent {
7384            announce: "http://private-tracker.example.com/announce",
7385            info: Info {
7386                length: data.len() as u64,
7387                name: "private_test",
7388                piece_length: 16384,
7389                pieces: &pieces,
7390                private: 1,
7391            },
7392        };
7393
7394        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7395        let meta = torrent_from_bytes(&bytes).unwrap();
7396        assert_eq!(meta.info.private, Some(1));
7397
7398        let storage = make_storage(&data, 16384);
7399        let config = test_config();
7400
7401        let (atx, amask) = test_alert_channel();
7402        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7403        let handle = TorrentHandle::from_torrent(
7404            meta,
7405            irontide_core::TorrentVersion::V1Only,
7406            None,
7407            dh,
7408            dm,
7409            config,
7410            test_dht_rx(),
7411            test_dht_rx(),
7412            None,
7413            None,
7414            crate::slot_tuner::SlotTuner::disabled(4),
7415            atx,
7416            amask,
7417            None,
7418            None,
7419            test_ban_manager(),
7420            test_ip_filter(),
7421            Arc::new(Vec::new()),
7422            None,
7423            None,
7424            Arc::new(crate::transport::NetworkFactory::tokio()),
7425            None, // M96: hash_pool
7426            Arc::new(crate::stats::SessionCounters::new()),
7427        )
7428        .await
7429        .unwrap();
7430
7431        let stats = handle.stats().await.unwrap();
7432        assert_eq!(stats.state, TorrentState::Downloading);
7433
7434        handle.shutdown().await.unwrap();
7435    }
7436
7437    // ---- Test 18: Magnet defers tracker announce ----
7438
7439    #[tokio::test]
7440    async fn magnet_no_tracker_before_metadata() {
7441        let magnet = Magnet {
7442            info_hashes: irontide_core::InfoHashes::v1_only(
7443                Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
7444            ),
7445            display_name: Some("magnet test".into()),
7446            trackers: vec![],
7447            peers: vec![],
7448            selected_files: None,
7449        };
7450
7451        let (atx, amask) = test_alert_channel();
7452        let (dm, _dj) = test_disk_manager();
7453        let handle = TorrentHandle::from_magnet(
7454            magnet,
7455            dm,
7456            test_config(),
7457            test_dht_rx(),
7458            test_dht_rx(),
7459            None,
7460            None,
7461            crate::slot_tuner::SlotTuner::disabled(4),
7462            atx,
7463            amask,
7464            None,
7465            None,
7466            test_ban_manager(),
7467            test_ip_filter(),
7468            Arc::new(Vec::new()),
7469            None,
7470            None,
7471            Arc::new(crate::transport::NetworkFactory::tokio()),
7472            None, // M96: hash_pool
7473            Arc::new(crate::stats::SessionCounters::new()),
7474        )
7475        .await
7476        .unwrap();
7477
7478        let stats = handle.stats().await.unwrap();
7479        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7480
7481        // With no trackers configured, no announces happen regardless of state.
7482        // Note: tracker announces ARE now allowed during FetchingMetadata for
7483        // magnets with &tr= URLs (needed to discover peers before metadata).
7484        tokio::time::sleep(Duration::from_millis(50)).await;
7485
7486        handle.shutdown().await.unwrap();
7487    }
7488
7489    // ---- Test 19: Pause and resume ----
7490
7491    #[tokio::test]
7492    async fn pause_and_resume() {
7493        let data = vec![0xEEu8; 32768];
7494        let meta = make_test_torrent(&data, 16384);
7495        let storage = make_storage(&data, 16384);
7496        let config = test_config();
7497        let (atx, amask) = test_alert_channel();
7498        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7499        let handle = TorrentHandle::from_torrent(
7500            meta,
7501            irontide_core::TorrentVersion::V1Only,
7502            None,
7503            dh,
7504            dm,
7505            config,
7506            test_dht_rx(),
7507            test_dht_rx(),
7508            None,
7509            None,
7510            crate::slot_tuner::SlotTuner::disabled(4),
7511            atx,
7512            amask,
7513            None,
7514            None,
7515            test_ban_manager(),
7516            test_ip_filter(),
7517            Arc::new(Vec::new()),
7518            None,
7519            None,
7520            Arc::new(crate::transport::NetworkFactory::tokio()),
7521            None, // M96: hash_pool
7522            Arc::new(crate::stats::SessionCounters::new()),
7523        )
7524        .await
7525        .unwrap();
7526
7527        let stats = handle.stats().await.unwrap();
7528        assert_eq!(stats.state, TorrentState::Downloading);
7529
7530        handle.pause().await.unwrap();
7531        tokio::time::sleep(Duration::from_millis(50)).await;
7532        let stats = handle.stats().await.unwrap();
7533        assert_eq!(stats.state, TorrentState::Paused);
7534
7535        handle.resume().await.unwrap();
7536        tokio::time::sleep(Duration::from_millis(50)).await;
7537        let stats = handle.stats().await.unwrap();
7538        assert_eq!(stats.state, TorrentState::Downloading);
7539
7540        handle.shutdown().await.unwrap();
7541    }
7542
7543    // ---- Test 20: Pause already paused is noop ----
7544
7545    #[tokio::test]
7546    async fn pause_already_paused_is_noop() {
7547        let data = vec![0xEEu8; 32768];
7548        let meta = make_test_torrent(&data, 16384);
7549        let storage = make_storage(&data, 16384);
7550        let config = test_config();
7551        let (atx, amask) = test_alert_channel();
7552        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7553        let handle = TorrentHandle::from_torrent(
7554            meta,
7555            irontide_core::TorrentVersion::V1Only,
7556            None,
7557            dh,
7558            dm,
7559            config,
7560            test_dht_rx(),
7561            test_dht_rx(),
7562            None,
7563            None,
7564            crate::slot_tuner::SlotTuner::disabled(4),
7565            atx,
7566            amask,
7567            None,
7568            None,
7569            test_ban_manager(),
7570            test_ip_filter(),
7571            Arc::new(Vec::new()),
7572            None,
7573            None,
7574            Arc::new(crate::transport::NetworkFactory::tokio()),
7575            None, // M96: hash_pool
7576            Arc::new(crate::stats::SessionCounters::new()),
7577        )
7578        .await
7579        .unwrap();
7580
7581        handle.pause().await.unwrap();
7582        tokio::time::sleep(Duration::from_millis(50)).await;
7583        handle.pause().await.unwrap(); // double pause is fine
7584        tokio::time::sleep(Duration::from_millis(50)).await;
7585        let stats = handle.stats().await.unwrap();
7586        assert_eq!(stats.state, TorrentState::Paused);
7587
7588        handle.shutdown().await.unwrap();
7589    }
7590
7591    // ---- Test 21: Incoming request served from storage ----
7592    //
7593    // Phase 1: Mock seeder feeds piece 0 to the torrent so it becomes verified.
7594    // Phase 2: Mock leecher connects and requests piece 0, verifying upload pipeline.
7595
7596    #[tokio::test]
7597    async fn incoming_request_served_from_storage() {
7598        let data = vec![0xABu8; 16384];
7599        let meta = make_test_torrent(&data, 16384);
7600        let info_hash = meta.info_hash;
7601        let storage = make_storage(&data, 16384);
7602
7603        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7604        let listen_addr = listener.local_addr().unwrap();
7605        drop(listener);
7606
7607        let config = TorrentConfig {
7608            listen_port: listen_addr.port(),
7609            ..test_config()
7610        };
7611
7612        let (atx, amask) = test_alert_channel();
7613        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7614        let handle = TorrentHandle::from_torrent(
7615            meta,
7616            irontide_core::TorrentVersion::V1Only,
7617            None,
7618            dh,
7619            dm,
7620            config,
7621            test_dht_rx(),
7622            test_dht_rx(),
7623            None,
7624            None,
7625            crate::slot_tuner::SlotTuner::disabled(4),
7626            atx,
7627            amask,
7628            None,
7629            None,
7630            test_ban_manager(),
7631            test_ip_filter(),
7632            Arc::new(Vec::new()),
7633            None,
7634            None,
7635            Arc::new(crate::transport::NetworkFactory::tokio()),
7636            None, // M96: hash_pool
7637            Arc::new(crate::stats::SessionCounters::new()),
7638        )
7639        .await
7640        .unwrap();
7641
7642        tokio::time::sleep(Duration::from_millis(50)).await;
7643
7644        // Phase 1: Seed the torrent with piece 0
7645        let seed_data = data.clone();
7646        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7647        let seeder_task = tokio::spawn(async move {
7648            let (reader, writer) = tokio::io::split(seed_stream);
7649            let mut writer = writer;
7650            let mut reader = reader;
7651
7652            let hs = Handshake::new(
7653                info_hash,
7654                Id20::from_hex("6666666666666666666666666666666666666666").unwrap(),
7655            );
7656            writer.write_all(&hs.to_bytes()).await.unwrap();
7657            writer.flush().await.unwrap();
7658            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7659            reader.read_exact(&mut hs_buf).await.unwrap();
7660
7661            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7662            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7663
7664            let _msg = framed_read.next().await; // ext handshake
7665            let ext_hs = ExtHandshake::new();
7666            let payload = ext_hs.to_bytes().unwrap();
7667            framed_write
7668                .send(Message::Extended { ext_id: 0, payload })
7669                .await
7670                .unwrap();
7671
7672            // Send bitfield + unchoke
7673            let mut bf = Bitfield::new(1);
7674            bf.set(0);
7675            framed_write
7676                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7677                .await
7678                .unwrap();
7679            framed_write.send(Message::Unchoke).await.unwrap();
7680
7681            // Respond to requests
7682            while let Some(Ok(msg)) = framed_read.next().await {
7683                if let Message::Request {
7684                    index,
7685                    begin,
7686                    length,
7687                } = msg
7688                {
7689                    let start = begin as usize;
7690                    let end = start + length as usize;
7691                    framed_write
7692                        .send(Message::Piece {
7693                            index,
7694                            begin,
7695                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7696                            data_1: Bytes::new(),
7697                        })
7698                        .await
7699                        .unwrap();
7700                }
7701            }
7702        });
7703
7704        // Wait for download to complete
7705        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7706        loop {
7707            tokio::time::sleep(Duration::from_millis(100)).await;
7708            let stats = handle.stats().await.unwrap();
7709            if stats.pieces_have == 1 {
7710                break;
7711            }
7712            assert!(
7713                tokio::time::Instant::now() <= deadline,
7714                "piece download did not complete within 5s"
7715            );
7716        }
7717
7718        // Phase 2: Connect a mock leecher to request piece 0 back
7719        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7720        let expected_data = data.clone();
7721        let leecher_task = tokio::spawn(async move {
7722            let (reader, writer) = tokio::io::split(leech_stream);
7723            let mut writer = writer;
7724            let mut reader = reader;
7725
7726            let hs = Handshake::new(
7727                info_hash,
7728                Id20::from_hex("7777777777777777777777777777777777777777").unwrap(),
7729            );
7730            writer.write_all(&hs.to_bytes()).await.unwrap();
7731            writer.flush().await.unwrap();
7732            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7733            reader.read_exact(&mut hs_buf).await.unwrap();
7734
7735            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7736            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7737
7738            let _msg = framed_read.next().await; // ext handshake
7739            let ext_hs = ExtHandshake::new();
7740            let payload = ext_hs.to_bytes().unwrap();
7741            framed_write
7742                .send(Message::Extended { ext_id: 0, payload })
7743                .await
7744                .unwrap();
7745
7746            // Send Interested and wait for Unchoke
7747            framed_write.send(Message::Interested).await.unwrap();
7748
7749            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7750            loop {
7751                tokio::select! {
7752                    msg = framed_read.next() => {
7753                        match msg {
7754                            Some(Ok(Message::Unchoke)) => { break; }
7755                            Some(Ok(_)) => {}
7756                            _ => panic!("connection closed before unchoke"),
7757                        }
7758                    }
7759                    () = tokio::time::sleep_until(deadline) => {
7760                        panic!("timed out waiting for unchoke");
7761                    }
7762                }
7763            }
7764
7765            // Request piece 0
7766            framed_write
7767                .send(Message::Request {
7768                    index: 0,
7769                    begin: 0,
7770                    length: 16384,
7771                })
7772                .await
7773                .unwrap();
7774
7775            // Read Piece response
7776            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7777            loop {
7778                tokio::select! {
7779                    msg = framed_read.next() => {
7780                        match msg {
7781                            Some(Ok(Message::Piece { index, begin, data_0, data_1 })) => {
7782                                assert_eq!(index, 0);
7783                                assert_eq!(begin, 0);
7784                                let _ = &data_1; // empty after wire round-trip
7785                                assert_eq!(data_0.as_ref(), expected_data.as_slice());
7786                                return; // success
7787                            }
7788                            Some(Ok(_)) => {}
7789                            Some(Err(e)) => panic!("error reading: {e}"),
7790                            None => panic!("connection closed before piece"),
7791                        }
7792                    }
7793                    () = tokio::time::sleep_until(deadline) => {
7794                        panic!("timed out waiting for piece data");
7795                    }
7796                }
7797            }
7798        });
7799
7800        // Wait for leecher to complete
7801        let result = tokio::time::timeout(Duration::from_secs(20), leecher_task).await;
7802        match result {
7803            Ok(Ok(())) => {}
7804            Ok(Err(e)) => panic!("leecher task panicked: {e}"),
7805            Err(elapsed) => panic!("test timed out after {elapsed}"),
7806        }
7807
7808        // Verify uploaded bytes
7809        let stats = handle.stats().await.unwrap();
7810        assert!(
7811            stats.uploaded > 0,
7812            "expected uploaded > 0, got {}",
7813            stats.uploaded
7814        );
7815
7816        handle.shutdown().await.unwrap();
7817        seeder_task.abort();
7818    }
7819
7820    // ---- Test 22: Seed ratio limit stops torrent ----
7821
7822    #[tokio::test]
7823    async fn seed_ratio_limit_stops_torrent() {
7824        // 1-piece torrent, ratio limit = 1.0
7825        // After downloading 16384 bytes and uploading 16384 bytes, ratio = 1.0 → stop
7826        let data = vec![0xCCu8; 16384];
7827        let meta = make_test_torrent(&data, 16384);
7828        let info_hash = meta.info_hash;
7829        let storage = make_storage(&data, 16384);
7830
7831        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7832        let listen_addr = listener.local_addr().unwrap();
7833        drop(listener);
7834
7835        let config = TorrentConfig {
7836            listen_port: listen_addr.port(),
7837            seed_ratio_limit: Some(1.0),
7838            ..test_config()
7839        };
7840
7841        let (atx, amask) = test_alert_channel();
7842        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7843        let handle = TorrentHandle::from_torrent(
7844            meta,
7845            irontide_core::TorrentVersion::V1Only,
7846            None,
7847            dh,
7848            dm,
7849            config,
7850            test_dht_rx(),
7851            test_dht_rx(),
7852            None,
7853            None,
7854            crate::slot_tuner::SlotTuner::disabled(4),
7855            atx,
7856            amask,
7857            None,
7858            None,
7859            test_ban_manager(),
7860            test_ip_filter(),
7861            Arc::new(Vec::new()),
7862            None,
7863            None,
7864            Arc::new(crate::transport::NetworkFactory::tokio()),
7865            None, // M96: hash_pool
7866            Arc::new(crate::stats::SessionCounters::new()),
7867        )
7868        .await
7869        .unwrap();
7870
7871        tokio::time::sleep(Duration::from_millis(50)).await;
7872
7873        // Phase 1: Seed the torrent with piece 0
7874        let seed_data = data.clone();
7875        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7876        let seeder_task = tokio::spawn(async move {
7877            let (reader, writer) = tokio::io::split(seed_stream);
7878            let mut writer = writer;
7879            let mut reader = reader;
7880
7881            let hs = Handshake::new(
7882                info_hash,
7883                Id20::from_hex("8888888888888888888888888888888888888888").unwrap(),
7884            );
7885            writer.write_all(&hs.to_bytes()).await.unwrap();
7886            writer.flush().await.unwrap();
7887            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7888            reader.read_exact(&mut hs_buf).await.unwrap();
7889
7890            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7891            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7892
7893            let _msg = framed_read.next().await;
7894            let ext_hs = ExtHandshake::new();
7895            let payload = ext_hs.to_bytes().unwrap();
7896            framed_write
7897                .send(Message::Extended { ext_id: 0, payload })
7898                .await
7899                .unwrap();
7900
7901            let mut bf = Bitfield::new(1);
7902            bf.set(0);
7903            framed_write
7904                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7905                .await
7906                .unwrap();
7907            framed_write.send(Message::Unchoke).await.unwrap();
7908
7909            while let Some(Ok(msg)) = framed_read.next().await {
7910                if let Message::Request {
7911                    index,
7912                    begin,
7913                    length,
7914                } = msg
7915                {
7916                    let start = begin as usize;
7917                    let end = start + length as usize;
7918                    framed_write
7919                        .send(Message::Piece {
7920                            index,
7921                            begin,
7922                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7923                            data_1: Bytes::new(),
7924                        })
7925                        .await
7926                        .unwrap();
7927                }
7928            }
7929        });
7930
7931        // Wait for download to complete (transitions to Seeding)
7932        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7933        loop {
7934            tokio::time::sleep(Duration::from_millis(100)).await;
7935            let stats = handle.stats().await.unwrap();
7936            if stats.state == TorrentState::Seeding {
7937                break;
7938            }
7939            assert!(
7940                tokio::time::Instant::now() <= deadline,
7941                "download did not complete within 5s"
7942            );
7943        }
7944
7945        // Phase 2: Connect leecher to request piece 0 — this should trigger ratio limit
7946        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7947        let leecher_task = tokio::spawn(async move {
7948            let (reader, writer) = tokio::io::split(leech_stream);
7949            let mut writer = writer;
7950            let mut reader = reader;
7951
7952            let hs = Handshake::new(
7953                info_hash,
7954                Id20::from_hex("9999999999999999999999999999999999999999").unwrap(),
7955            );
7956            writer.write_all(&hs.to_bytes()).await.unwrap();
7957            writer.flush().await.unwrap();
7958            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7959            reader.read_exact(&mut hs_buf).await.unwrap();
7960
7961            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7962            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7963
7964            let _msg = framed_read.next().await;
7965            let ext_hs = ExtHandshake::new();
7966            let payload = ext_hs.to_bytes().unwrap();
7967            framed_write
7968                .send(Message::Extended { ext_id: 0, payload })
7969                .await
7970                .unwrap();
7971
7972            framed_write.send(Message::Interested).await.unwrap();
7973
7974            // Wait for unchoke
7975            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7976            loop {
7977                tokio::select! {
7978                    msg = framed_read.next() => {
7979                        match msg {
7980                            Some(Ok(Message::Unchoke)) => break,
7981                            Some(Ok(_)) => {}
7982                            _ => return, // connection may close due to ratio shutdown
7983                        }
7984                    }
7985                    () = tokio::time::sleep_until(deadline) => return,
7986                }
7987            }
7988
7989            // Request piece 0
7990            framed_write
7991                .send(Message::Request {
7992                    index: 0,
7993                    begin: 0,
7994                    length: 16384,
7995                })
7996                .await
7997                .unwrap();
7998
7999            // Read until connection closes (the torrent may stop and disconnect us)
8000            while let Some(Ok(_msg)) = framed_read.next().await {}
8001        });
8002
8003        // Wait for state to become Stopped
8004        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
8005        loop {
8006            tokio::time::sleep(Duration::from_millis(100)).await;
8007            let stats = handle.stats().await.unwrap();
8008            if stats.state == TorrentState::Stopped {
8009                assert!(
8010                    stats.uploaded >= 16384,
8011                    "expected uploaded >= 16384, got {}",
8012                    stats.uploaded
8013                );
8014                break;
8015            }
8016            if tokio::time::Instant::now() > deadline {
8017                let stats = handle.stats().await.unwrap();
8018                panic!(
8019                    "expected Stopped, got {:?}, uploaded={}, downloaded={}",
8020                    stats.state, stats.uploaded, stats.downloaded
8021                );
8022            }
8023        }
8024
8025        handle.shutdown().await.unwrap();
8026        seeder_task.abort();
8027        leecher_task.abort();
8028    }
8029
8030    // ---- Test 23: Resume with seeded storage starts as seeder ----
8031
8032    #[tokio::test]
8033    async fn resume_with_seeded_storage() {
8034        let data = vec![0xDDu8; 32768]; // 2 pieces
8035        let meta = make_test_torrent(&data, 16384);
8036        let storage = make_seeded_storage(&data, 16384);
8037        let config = test_config();
8038
8039        let (atx, amask) = test_alert_channel();
8040        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8041        let handle = TorrentHandle::from_torrent(
8042            meta,
8043            irontide_core::TorrentVersion::V1Only,
8044            None,
8045            dh,
8046            dm,
8047            config,
8048            test_dht_rx(),
8049            test_dht_rx(),
8050            None,
8051            None,
8052            crate::slot_tuner::SlotTuner::disabled(4),
8053            atx,
8054            amask,
8055            None,
8056            None,
8057            test_ban_manager(),
8058            test_ip_filter(),
8059            Arc::new(Vec::new()),
8060            None,
8061            None,
8062            Arc::new(crate::transport::NetworkFactory::tokio()),
8063            None, // M96: hash_pool
8064            Arc::new(crate::stats::SessionCounters::new()),
8065        )
8066        .await
8067        .unwrap();
8068
8069        // Give the actor time to verify existing pieces
8070        tokio::time::sleep(Duration::from_millis(100)).await;
8071
8072        let stats = handle.stats().await.unwrap();
8073        assert_eq!(
8074            stats.state,
8075            TorrentState::Seeding,
8076            "should start as seeder with all pieces verified"
8077        );
8078        assert_eq!(stats.pieces_have, 2);
8079        assert_eq!(stats.pieces_total, 2);
8080
8081        handle.shutdown().await.unwrap();
8082    }
8083
8084    // ---- Test: save_resume_data captures state ----
8085
8086    #[tokio::test]
8087    async fn save_resume_data_captures_state() {
8088        let data = vec![0xAB; 32768];
8089        let meta = make_test_torrent(&data, 16384);
8090        let info_hash = meta.info_hash;
8091        let storage = make_storage(&data, 16384);
8092        let config = test_config();
8093
8094        let (atx, amask) = test_alert_channel();
8095        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8096        let handle = TorrentHandle::from_torrent(
8097            meta,
8098            irontide_core::TorrentVersion::V1Only,
8099            None,
8100            dh,
8101            dm,
8102            config,
8103            test_dht_rx(),
8104            test_dht_rx(),
8105            None,
8106            None,
8107            crate::slot_tuner::SlotTuner::disabled(4),
8108            atx,
8109            amask,
8110            None,
8111            None,
8112            test_ban_manager(),
8113            test_ip_filter(),
8114            Arc::new(Vec::new()),
8115            None,
8116            None,
8117            Arc::new(crate::transport::NetworkFactory::tokio()),
8118            None, // M96: hash_pool
8119            Arc::new(crate::stats::SessionCounters::new()),
8120        )
8121        .await
8122        .unwrap();
8123
8124        // Give actor time to start
8125        tokio::time::sleep(Duration::from_millis(50)).await;
8126
8127        let rd = handle.save_resume_data().await.unwrap();
8128
8129        assert_eq!(rd.file_format, "libtorrent resume file");
8130        assert_eq!(rd.file_version, 1);
8131        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8132        assert_eq!(rd.name, "test");
8133        assert_eq!(rd.save_path, "/tmp");
8134        assert_eq!(rd.paused, 0);
8135        // No pieces downloaded yet — bitfield should be all zeros
8136        assert!(!rd.pieces.is_empty());
8137        // Stats should be zero for a freshly started torrent with no peers
8138        assert_eq!(rd.total_uploaded, 0);
8139        assert_eq!(rd.total_downloaded, 0);
8140
8141        handle.shutdown().await.unwrap();
8142    }
8143
8144    // ---- Test: save_resume_data for seeder ----
8145
8146    #[tokio::test]
8147    async fn save_resume_data_seeder() {
8148        let data = vec![0xCD; 32768];
8149        let meta = make_test_torrent(&data, 16384);
8150        let info_hash = meta.info_hash;
8151        let storage = make_seeded_storage(&data, 16384);
8152        let config = test_config();
8153
8154        let (atx, amask) = test_alert_channel();
8155        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8156        let handle = TorrentHandle::from_torrent(
8157            meta,
8158            irontide_core::TorrentVersion::V1Only,
8159            None,
8160            dh,
8161            dm,
8162            config,
8163            test_dht_rx(),
8164            test_dht_rx(),
8165            None,
8166            None,
8167            crate::slot_tuner::SlotTuner::disabled(4),
8168            atx,
8169            amask,
8170            None,
8171            None,
8172            test_ban_manager(),
8173            test_ip_filter(),
8174            Arc::new(Vec::new()),
8175            None,
8176            None,
8177            Arc::new(crate::transport::NetworkFactory::tokio()),
8178            None, // M96: hash_pool
8179            Arc::new(crate::stats::SessionCounters::new()),
8180        )
8181        .await
8182        .unwrap();
8183
8184        // Give actor time to verify pieces and switch to seeding
8185        tokio::time::sleep(Duration::from_millis(100)).await;
8186
8187        let rd = handle.save_resume_data().await.unwrap();
8188
8189        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8190        assert_eq!(rd.name, "test");
8191        assert_eq!(rd.seed_mode, 1, "seeder should have seed_mode=1");
8192        assert_eq!(rd.paused, 0);
8193        // All pieces should be marked in the bitfield
8194        // 2 pieces -> 1 byte, top 2 bits set = 0b1100_0000 = 0xC0
8195        assert_eq!(rd.pieces.len(), 1);
8196        assert_eq!(
8197            rd.pieces[0] & 0xC0,
8198            0xC0,
8199            "both pieces should be marked complete"
8200        );
8201
8202        handle.shutdown().await.unwrap();
8203    }
8204
8205    // ---- Test: save_resume_data for paused torrent ----
8206
8207    #[tokio::test]
8208    async fn save_resume_data_paused() {
8209        let data = vec![0xEF; 16384];
8210        let meta = make_test_torrent(&data, 16384);
8211        let storage = make_storage(&data, 16384);
8212        let config = test_config();
8213
8214        let (atx, amask) = test_alert_channel();
8215        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8216        let handle = TorrentHandle::from_torrent(
8217            meta,
8218            irontide_core::TorrentVersion::V1Only,
8219            None,
8220            dh,
8221            dm,
8222            config,
8223            test_dht_rx(),
8224            test_dht_rx(),
8225            None,
8226            None,
8227            crate::slot_tuner::SlotTuner::disabled(4),
8228            atx,
8229            amask,
8230            None,
8231            None,
8232            test_ban_manager(),
8233            test_ip_filter(),
8234            Arc::new(Vec::new()),
8235            None,
8236            None,
8237            Arc::new(crate::transport::NetworkFactory::tokio()),
8238            None, // M96: hash_pool
8239            Arc::new(crate::stats::SessionCounters::new()),
8240        )
8241        .await
8242        .unwrap();
8243
8244        tokio::time::sleep(Duration::from_millis(50)).await;
8245        handle.pause().await.unwrap();
8246        tokio::time::sleep(Duration::from_millis(50)).await;
8247
8248        let rd = handle.save_resume_data().await.unwrap();
8249        assert_eq!(rd.paused, 1, "paused torrent should have paused=1");
8250        assert_eq!(rd.seed_mode, 0);
8251
8252        handle.shutdown().await.unwrap();
8253    }
8254
8255    // ---- Test: set_file_priority and read back ----
8256
8257    #[tokio::test]
8258    async fn set_file_priority_and_read_back() {
8259        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8260        let mut torrent_bytes = b"d4:info".to_vec();
8261        torrent_bytes.extend_from_slice(info_bytes);
8262        torrent_bytes.push(b'e');
8263
8264        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8265        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8266        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8267        let config = TorrentConfig {
8268            listen_port: 0,
8269            ..Default::default()
8270        };
8271
8272        let (atx, amask) = test_alert_channel();
8273        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8274        let handle = TorrentHandle::from_torrent(
8275            meta,
8276            irontide_core::TorrentVersion::V1Only,
8277            None,
8278            dh,
8279            dm,
8280            config,
8281            test_dht_rx(),
8282            test_dht_rx(),
8283            None,
8284            None,
8285            crate::slot_tuner::SlotTuner::disabled(4),
8286            atx,
8287            amask,
8288            None,
8289            None,
8290            test_ban_manager(),
8291            test_ip_filter(),
8292            Arc::new(Vec::new()),
8293            None,
8294            None,
8295            Arc::new(crate::transport::NetworkFactory::tokio()),
8296            None, // M96: hash_pool
8297            Arc::new(crate::stats::SessionCounters::new()),
8298        )
8299        .await
8300        .unwrap();
8301
8302        // Default priorities should all be Normal
8303        let prios = handle.file_priorities().await.unwrap();
8304        assert_eq!(prios.len(), 2);
8305        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8306
8307        // Set file 0 to Skip
8308        handle
8309            .set_file_priority(0, FilePriority::Skip)
8310            .await
8311            .unwrap();
8312
8313        let prios = handle.file_priorities().await.unwrap();
8314        assert_eq!(prios[0], FilePriority::Skip);
8315        assert_eq!(prios[1], FilePriority::Normal);
8316
8317        // Invalid index should error
8318        let result = handle.set_file_priority(99, FilePriority::High).await;
8319        assert!(result.is_err());
8320
8321        handle.shutdown().await.unwrap();
8322        tokio::time::sleep(Duration::from_millis(50)).await;
8323    }
8324
8325    /// Spawn a running multi-file torrent (3 files, 4 pieces, one shared
8326    /// boundary piece: file b at 150 B spans pieces 1-2, file c spans 2-3) for
8327    /// end-to-end `SetFilePriority` arm coverage.
8328    async fn spawn_test_torrent_multifile() -> TorrentHandle {
8329        let meta = make_multi_file_meta(&[(100, "a.bin"), (150, "b.bin"), (100, "c.bin")], 100);
8330        let lengths = Lengths::new(350, 100, DEFAULT_CHUNK_SIZE);
8331        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8332        let config = TorrentConfig {
8333            listen_port: 0,
8334            ..Default::default()
8335        };
8336        let (atx, amask) = test_alert_channel();
8337        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8338        TorrentHandle::from_torrent(
8339            meta,
8340            irontide_core::TorrentVersion::V1Only,
8341            None,
8342            dh,
8343            dm,
8344            config,
8345            test_dht_rx(),
8346            test_dht_rx(),
8347            None,
8348            None,
8349            crate::slot_tuner::SlotTuner::disabled(4),
8350            atx,
8351            amask,
8352            None,
8353            None,
8354            test_ban_manager(),
8355            test_ip_filter(),
8356            Arc::new(Vec::new()),
8357            None,
8358            None,
8359            Arc::new(crate::transport::NetworkFactory::tokio()),
8360            None,
8361            Arc::new(crate::stats::SessionCounters::new()),
8362        )
8363        .await
8364        .unwrap()
8365    }
8366
8367    /// M246 characterization: the observable post-conditions of a file-priority
8368    /// change — `file_priorities()` round-trip and the invalid-index error — are
8369    /// synchronous and MUST survive the recv-loop hardening. Only the order-map
8370    /// *build* moves to the 1 s tick; the priority/wanted/atomic state that gates
8371    /// dispatch stays synchronous, so this surface is unchanged before and after.
8372    #[tokio::test]
8373    async fn set_file_priority_updates_wanted_and_priorities() {
8374        let handle = spawn_test_torrent_multifile().await;
8375
8376        let prios = handle.file_priorities().await.unwrap();
8377        assert_eq!(prios.len(), 3);
8378        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8379
8380        handle
8381            .set_file_priority(1, FilePriority::Skip)
8382            .await
8383            .unwrap();
8384        assert_eq!(
8385            handle.file_priorities().await.unwrap()[1],
8386            FilePriority::Skip
8387        );
8388
8389        handle
8390            .set_file_priority(1, FilePriority::Normal)
8391            .await
8392            .unwrap();
8393        assert_eq!(
8394            handle.file_priorities().await.unwrap()[1],
8395            FilePriority::Normal
8396        );
8397
8398        // Invalid index still errors synchronously.
8399        assert!(
8400            handle
8401                .set_file_priority(99, FilePriority::High)
8402                .await
8403                .is_err()
8404        );
8405
8406        handle.shutdown().await.unwrap();
8407        tokio::time::sleep(Duration::from_millis(50)).await;
8408    }
8409
8410    /// Build a synchronous (non-spawned) multi-file `TorrentActor` for direct
8411    /// method-level testing of the M246 range-scoped helpers. Populates
8412    /// `meta`, `lengths`, `cached_files`, `file_priorities` (all `Normal`),
8413    /// `atomic_states`, and `piece_tracker` sized to the layout, on top of
8414    /// `for_throttle_test`'s channel scaffolding. Must run inside a tokio runtime.
8415    fn priority_test_actor(files: &[(u64, &str)], piece_length: u64) -> TorrentActor {
8416        use irontide_storage::Bitfield;
8417        let meta = make_multi_file_meta(files, piece_length);
8418        let total: u64 = files.iter().map(|(l, _)| *l).sum();
8419        let lengths = Lengths::new(total, piece_length, DEFAULT_CHUNK_SIZE);
8420        let num_pieces = lengths.num_pieces();
8421        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8422
8423        let mut actor = TorrentActor::for_throttle_test(num_pieces, 0);
8424        actor.file_priorities = vec![FilePriority::Normal; files.len()];
8425        actor.wanted_pieces = crate::piece_selector::build_wanted_pieces(
8426            &actor.file_priorities,
8427            &file_lengths,
8428            &lengths,
8429        );
8430        actor.cached_files = Some(build_cached_file_info(&meta, &lengths));
8431
8432        let we_have = Bitfield::new(num_pieces);
8433        actor.atomic_states = Some(Arc::new(crate::piece_reservation::AtomicPieceStates::new(
8434            num_pieces,
8435            &we_have,
8436            &actor.wanted_pieces,
8437        )));
8438        actor.piece_tracker = Some(crate::piece_reservation::PieceTracker::new(
8439            num_pieces,
8440            &we_have,
8441            &actor.wanted_pieces,
8442        ));
8443        actor.meta = Some(meta);
8444        actor.lengths = Some(lengths);
8445        actor
8446    }
8447
8448    /// M246 D4: the scoped recompute must leave `wanted_pieces` byte-identical
8449    /// to a full `build_wanted_pieces` for every file, across a misaligned
8450    /// layout with multiple shared boundary pieces (b/c share piece 2; c/d
8451    /// share piece 3).
8452    #[tokio::test]
8453    async fn apply_file_priority_scoped_matches_full_rebuild() {
8454        let files: &[(u64, &str)] = &[(100, "a"), (150, "b"), (100, "c"), (250, "d")];
8455        let piece_length = 100;
8456        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8457        for skip_idx in 0..files.len() {
8458            let mut actor = priority_test_actor(files, piece_length);
8459            actor
8460                .apply_file_priority_scoped(skip_idx, FilePriority::Skip)
8461                .unwrap();
8462
8463            let mut ref_prios = vec![FilePriority::Normal; files.len()];
8464            ref_prios[skip_idx] = FilePriority::Skip;
8465            let reference = crate::piece_selector::build_wanted_pieces(
8466                &ref_prios,
8467                &file_lengths,
8468                actor.lengths.as_ref().unwrap(),
8469            );
8470            for p in 0..actor.num_pieces {
8471                assert_eq!(
8472                    actor.wanted_pieces.get(p),
8473                    reference.get(p),
8474                    "piece {p} mismatch after scoped skip of file {skip_idx}"
8475                );
8476            }
8477        }
8478    }
8479
8480    /// M246 D4: sub-piece-sized files (a,b,c all inside piece 0). The shared
8481    /// piece stays wanted while ANY overlapping file is non-skip, and unwants
8482    /// only when all overlapping files are skipped.
8483    #[tokio::test]
8484    async fn apply_file_priority_scoped_handles_sub_piece_files() {
8485        let files: &[(u64, &str)] = &[(30, "a"), (30, "b"), (40, "c"), (100, "d")];
8486        let mut actor = priority_test_actor(files, 100);
8487
8488        actor
8489            .apply_file_priority_scoped(0, FilePriority::Skip)
8490            .unwrap();
8491        assert!(
8492            actor.wanted_pieces.get(0),
8493            "piece 0 wanted: b,c still want it"
8494        );
8495        actor
8496            .apply_file_priority_scoped(1, FilePriority::Skip)
8497            .unwrap();
8498        actor
8499            .apply_file_priority_scoped(2, FilePriority::Skip)
8500            .unwrap();
8501        assert!(
8502            !actor.wanted_pieces.get(0),
8503            "piece 0 unwanted: a,b,c all skip"
8504        );
8505        assert!(
8506            actor.wanted_pieces.get(1),
8507            "piece 1 still wanted (d Normal)"
8508        );
8509
8510        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8511        let prios = vec![
8512            FilePriority::Skip,
8513            FilePriority::Skip,
8514            FilePriority::Skip,
8515            FilePriority::Normal,
8516        ];
8517        let reference = crate::piece_selector::build_wanted_pieces(
8518            &prios,
8519            &file_lengths,
8520            actor.lengths.as_ref().unwrap(),
8521        );
8522        for p in 0..actor.num_pieces {
8523            assert_eq!(
8524                actor.wanted_pieces.get(p),
8525                reference.get(p),
8526                "piece {p} mismatch"
8527            );
8528        }
8529    }
8530
8531    /// M246 D4: a zero-length file (middle of the layout) yields an empty range
8532    /// and must not panic; the result still matches a full rebuild.
8533    #[tokio::test]
8534    async fn apply_file_priority_scoped_zero_length_file_no_panic() {
8535        let files: &[(u64, &str)] = &[(100, "a"), (0, "empty"), (100, "c")];
8536        let mut actor = priority_test_actor(files, 100);
8537
8538        let r = actor
8539            .apply_file_priority_scoped(1, FilePriority::Skip)
8540            .unwrap();
8541        assert!(
8542            r.0 > r.1,
8543            "zero-length file yields an empty range, got {r:?}"
8544        );
8545
8546        actor
8547            .apply_file_priority_scoped(0, FilePriority::Skip)
8548            .unwrap();
8549
8550        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8551        let prios = vec![FilePriority::Skip, FilePriority::Skip, FilePriority::Normal];
8552        let reference = crate::piece_selector::build_wanted_pieces(
8553            &prios,
8554            &file_lengths,
8555            actor.lengths.as_ref().unwrap(),
8556        );
8557        for p in 0..actor.num_pieces {
8558            assert_eq!(
8559                actor.wanted_pieces.get(p),
8560                reference.get(p),
8561                "piece {p} mismatch"
8562            );
8563        }
8564    }
8565
8566    /// M246: `sync_piece_states_for_range` touches ONLY pieces inside the range;
8567    /// pieces outside stay at their prior atomic state.
8568    #[tokio::test]
8569    async fn sync_piece_states_for_range_only_touches_range() {
8570        let files: &[(u64, &str)] = &[(200, "a"), (200, "b"), (200, "c")];
8571        let mut actor = priority_test_actor(files, 100); // 6 pieces; file 1 = pieces 2,3
8572        let (first, last) = actor
8573            .apply_file_priority_scoped(1, FilePriority::Skip)
8574            .unwrap();
8575        assert_eq!((first, last), (2, 3));
8576        actor.sync_piece_states_for_range(first, last);
8577
8578        let atomic = actor.atomic_states.as_ref().unwrap();
8579        assert_eq!(
8580            atomic.get(2),
8581            crate::piece_reservation::PieceState::Unwanted
8582        );
8583        assert_eq!(
8584            atomic.get(3),
8585            crate::piece_reservation::PieceState::Unwanted
8586        );
8587        for p in [0u32, 1, 4, 5] {
8588            assert_eq!(
8589                atomic.get(p),
8590                crate::piece_reservation::PieceState::Available,
8591                "piece {p} outside the range must be untouched"
8592            );
8593        }
8594    }
8595
8596    /// M246 FINDING-1 regression: the order-map rebuild is DEFERRED (no rebuild
8597    /// before the tick), a batch coalesces to ONE rebuild, the generation comes
8598    /// from the actor-owned monotone counter (advances by exactly 1), and the
8599    /// SECOND of two back-to-back batch changes is NOT lost — the failure mode
8600    /// of the rejected Candidate H (read published gen, async publish → drop).
8601    #[tokio::test]
8602    async fn order_map_coalesces_and_gen_is_monotone() {
8603        // 3 files × 200 B, piece_length 100 → f0=[0,1], f1=[2,3], f2=[4,5].
8604        let mut actor = priority_test_actor(&[(200, "a"), (200, "b"), (200, "c")], 100);
8605        let gen0 = actor.order_map_tx.borrow().generation;
8606
8607        // Two back-to-back scoped priority changes (the batch case). The arm sets
8608        // the dirty flag; we drive the helper + flag directly here.
8609        actor
8610            .apply_file_priority_scoped(0, FilePriority::Skip)
8611            .unwrap();
8612        actor.order_map_dirty = true;
8613        actor
8614            .apply_file_priority_scoped(2, FilePriority::Skip)
8615            .unwrap();
8616        actor.order_map_dirty = true;
8617        assert_eq!(
8618            actor.order_map_tx.borrow().generation,
8619            gen0,
8620            "no order-map rebuild before the tick"
8621        );
8622
8623        // The tick rebuilds ONCE, capturing BOTH changes; gen advances by 1.
8624        actor.rebuild_order_map_now();
8625        assert_eq!(
8626            actor.order_map_tx.borrow().generation,
8627            gen0 + 1,
8628            "exactly one coalesced rebuild"
8629        );
8630        assert!(!actor.order_map_dirty, "dirty flag cleared after rebuild");
8631
8632        // Both skipped files' pieces are absent; file 1's remain present — proof
8633        // the second change was not dropped.
8634        let map = actor.order_map_tx.borrow();
8635        for p in [0u32, 1, 4, 5] {
8636            assert!(
8637                !map.order.contains(&p),
8638                "skipped piece {p} must be absent from the order"
8639            );
8640        }
8641        for p in [2u32, 3] {
8642            assert!(
8643                map.order.contains(&p),
8644                "wanted piece {p} must be present in the order"
8645            );
8646        }
8647    }
8648
8649    /// M246 Q6: the post-recheck reconfigure path delegates to
8650    /// `rebuild_order_map_now`, which clears a pending dirty flag so the next
8651    /// tick does not redundantly rebuild.
8652    #[tokio::test]
8653    async fn rebuild_order_map_now_clears_dirty_flag() {
8654        let mut actor = priority_test_actor(&[(200, "a"), (200, "b")], 100);
8655        actor.order_map_dirty = true;
8656        actor.rebuild_order_map_now();
8657        assert!(!actor.order_map_dirty);
8658    }
8659
8660    #[tokio::test]
8661    async fn resume_data_preserves_file_priorities() {
8662        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8663        let mut torrent_bytes = b"d4:info".to_vec();
8664        torrent_bytes.extend_from_slice(info_bytes);
8665        torrent_bytes.push(b'e');
8666
8667        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8668        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8669        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8670        let config = TorrentConfig {
8671            listen_port: 0,
8672            ..Default::default()
8673        };
8674
8675        let (atx, amask) = test_alert_channel();
8676        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8677        let handle = TorrentHandle::from_torrent(
8678            meta,
8679            irontide_core::TorrentVersion::V1Only,
8680            None,
8681            dh,
8682            dm,
8683            config,
8684            test_dht_rx(),
8685            test_dht_rx(),
8686            None,
8687            None,
8688            crate::slot_tuner::SlotTuner::disabled(4),
8689            atx,
8690            amask,
8691            None,
8692            None,
8693            test_ban_manager(),
8694            test_ip_filter(),
8695            Arc::new(Vec::new()),
8696            None,
8697            None,
8698            Arc::new(crate::transport::NetworkFactory::tokio()),
8699            None, // M96: hash_pool
8700            Arc::new(crate::stats::SessionCounters::new()),
8701        )
8702        .await
8703        .unwrap();
8704
8705        // Set file priorities
8706        handle
8707            .set_file_priority(0, FilePriority::High)
8708            .await
8709            .unwrap();
8710        handle
8711            .set_file_priority(1, FilePriority::Skip)
8712            .await
8713            .unwrap();
8714
8715        // Save resume data
8716        let rd = handle.save_resume_data().await.unwrap();
8717        assert_eq!(rd.file_priority, vec![7, 0]); // High=7, Skip=0
8718
8719        // Verify bencode round-trip
8720        let encoded = irontide_bencode::to_bytes(&rd).unwrap();
8721        let decoded: irontide_core::FastResumeData =
8722            irontide_bencode::from_bytes(&encoded).unwrap();
8723        assert_eq!(decoded.file_priority, vec![7, 0]);
8724
8725        handle.shutdown().await.unwrap();
8726        tokio::time::sleep(Duration::from_millis(50)).await;
8727    }
8728
8729    // ---- Rate limiting integration tests (M14) ----
8730
8731    #[tokio::test]
8732    async fn upload_rate_limiting_caps_throughput() {
8733        // Test that per-torrent upload rate limiting gates serve_incoming_requests.
8734        // We use a very low rate (1 KB/s) so the 16 KB piece requires ~16 seconds.
8735        // We verify: 1) piece does NOT arrive within 200ms (bucket too small),
8736        //            2) the torrent actor is alive and functional.
8737        let data = vec![0xAB; 16384]; // 1 piece
8738        let meta = make_test_torrent(&data, 16384);
8739        let info_hash = meta.info_hash;
8740        let storage = make_seeded_storage(&data, 16384);
8741
8742        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8743        let listen_addr = listener.local_addr().unwrap();
8744
8745        let config = TorrentConfig {
8746            listen_port: listen_addr.port(),
8747            upload_rate_limit: 1024, // 1 KB/s — way too slow for 16 KB chunk
8748            ..test_config()
8749        };
8750
8751        drop(listener);
8752        let (atx, amask) = test_alert_channel();
8753        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8754        let handle = TorrentHandle::from_torrent(
8755            meta,
8756            irontide_core::TorrentVersion::V1Only,
8757            None,
8758            dh,
8759            dm,
8760            config,
8761            test_dht_rx(),
8762            test_dht_rx(),
8763            None,
8764            None,
8765            crate::slot_tuner::SlotTuner::disabled(4),
8766            atx,
8767            amask,
8768            None,
8769            None,
8770            test_ban_manager(),
8771            test_ip_filter(),
8772            Arc::new(Vec::new()),
8773            None,
8774            None,
8775            Arc::new(crate::transport::NetworkFactory::tokio()),
8776            None, // M96: hash_pool
8777            Arc::new(crate::stats::SessionCounters::new()),
8778        )
8779        .await
8780        .unwrap();
8781
8782        tokio::time::sleep(Duration::from_millis(50)).await;
8783
8784        // Connect mock leecher (raw handshake + framed messages)
8785        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8786        let (reader, writer) = tokio::io::split(stream);
8787        let mut writer = writer;
8788        let mut reader = reader;
8789
8790        let hs = Handshake::new(
8791            info_hash,
8792            Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8793        );
8794        writer.write_all(&hs.to_bytes()).await.unwrap();
8795        writer.flush().await.unwrap();
8796        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8797        reader.read_exact(&mut hs_buf).await.unwrap();
8798
8799        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8800        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8801
8802        // Read ext handshake + bitfield
8803        let _msg = framed_read.next().await;
8804        let ext_hs = ExtHandshake::new();
8805        let payload = ext_hs.to_bytes().unwrap();
8806        framed_write
8807            .send(Message::Extended { ext_id: 0, payload })
8808            .await
8809            .unwrap();
8810
8811        // Read the bitfield
8812        let _bf_msg = framed_read.next().await;
8813
8814        // Express interest
8815        framed_write.send(Message::Interested).await.unwrap();
8816
8817        // Wait for unchoke
8818        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
8819        loop {
8820            tokio::select! {
8821                msg = framed_read.next() => {
8822                    match msg {
8823                        Some(Ok(Message::Unchoke)) => break,
8824                        Some(Ok(_)) => {}
8825                        _ => panic!("connection closed before unchoke"),
8826                    }
8827                }
8828                () = tokio::time::sleep_until(deadline) => {
8829                    panic!("timed out waiting for unchoke");
8830                }
8831            }
8832        }
8833
8834        // Request piece 0
8835        framed_write
8836            .send(Message::Request {
8837                index: 0,
8838                begin: 0,
8839                length: 16384,
8840            })
8841            .await
8842            .unwrap();
8843
8844        // At 1 KB/s, the bucket accumulates ~100 bytes per 100ms tick (max burst = 1024).
8845        // A 16 KB chunk needs 16384 tokens, so it should NOT be served quickly.
8846        // We wait 2 seconds — at 1 KB/s we'd have at most 2 KB, still < 16 KB.
8847        let mut got_piece = false;
8848        if let Ok(true) = tokio::time::timeout(Duration::from_secs(2), async {
8849            loop {
8850                match framed_read.next().await {
8851                    Some(Ok(Message::Piece { .. })) => return true,
8852                    Some(Ok(_)) => {}
8853                    _ => return false,
8854                }
8855            }
8856        })
8857        .await
8858        {
8859            got_piece = true;
8860        }
8861
8862        // Piece should NOT have arrived in 2 seconds (would need 16s at 1 KB/s)
8863        assert!(
8864            !got_piece,
8865            "piece should be delayed by rate limiter (1 KB/s for 16 KB chunk)"
8866        );
8867
8868        // Verify actor is still alive
8869        let stats = handle.stats().await.unwrap();
8870        assert_eq!(stats.uploaded, 0); // nothing served yet
8871
8872        handle.shutdown().await.unwrap();
8873    }
8874
8875    #[tokio::test]
8876    async fn unlimited_rate_has_no_effect() {
8877        // Default config (rate = 0) should behave identically to pre-M14
8878        let data = vec![0xAB; 32768];
8879        let meta = make_test_torrent(&data, 16384);
8880        let storage = make_storage(&data, 16384);
8881        let config = test_config();
8882
8883        // Rate limits are 0 (unlimited) by default
8884        assert_eq!(config.upload_rate_limit, 0);
8885        assert_eq!(config.download_rate_limit, 0);
8886
8887        let (atx, amask) = test_alert_channel();
8888        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8889        let handle = TorrentHandle::from_torrent(
8890            meta,
8891            irontide_core::TorrentVersion::V1Only,
8892            None,
8893            dh,
8894            dm,
8895            config,
8896            test_dht_rx(),
8897            test_dht_rx(),
8898            None,
8899            None,
8900            crate::slot_tuner::SlotTuner::disabled(4),
8901            atx,
8902            amask,
8903            None,
8904            None,
8905            test_ban_manager(),
8906            test_ip_filter(),
8907            Arc::new(Vec::new()),
8908            None,
8909            None,
8910            Arc::new(crate::transport::NetworkFactory::tokio()),
8911            None, // M96: hash_pool
8912            Arc::new(crate::stats::SessionCounters::new()),
8913        )
8914        .await
8915        .unwrap();
8916
8917        let stats = handle.stats().await.unwrap();
8918        assert_eq!(stats.state, TorrentState::Downloading);
8919        assert_eq!(stats.pieces_total, 2);
8920
8921        handle.shutdown().await.unwrap();
8922    }
8923
8924    #[tokio::test]
8925    async fn download_rate_limiting_throttles_requests() {
8926        // Test that download_rate_limit prevents sending requests when budget exhausted.
8927        // With 1 KB/s limit and 16 KB chunks, budget is exhausted almost immediately.
8928        let data = vec![0xAB; 32768];
8929        let meta = make_test_torrent(&data, 16384);
8930        let info_hash = meta.info_hash;
8931        let storage = make_storage(&data, 16384);
8932
8933        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8934        let listen_addr = listener.local_addr().unwrap();
8935
8936        let config = TorrentConfig {
8937            listen_port: listen_addr.port(),
8938            download_rate_limit: 1024, // Very low: 1 KB/s
8939            ..test_config()
8940        };
8941
8942        drop(listener);
8943        let (atx, amask) = test_alert_channel();
8944        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8945        let handle = TorrentHandle::from_torrent(
8946            meta,
8947            irontide_core::TorrentVersion::V1Only,
8948            None,
8949            dh,
8950            dm,
8951            config,
8952            test_dht_rx(),
8953            test_dht_rx(),
8954            None,
8955            None,
8956            crate::slot_tuner::SlotTuner::disabled(4),
8957            atx,
8958            amask,
8959            None,
8960            None,
8961            test_ban_manager(),
8962            test_ip_filter(),
8963            Arc::new(Vec::new()),
8964            None,
8965            None,
8966            Arc::new(crate::transport::NetworkFactory::tokio()),
8967            None, // M96: hash_pool
8968            Arc::new(crate::stats::SessionCounters::new()),
8969        )
8970        .await
8971        .unwrap();
8972
8973        tokio::time::sleep(Duration::from_millis(50)).await;
8974
8975        // Connect mock seeder
8976        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8977        let (reader, writer) = tokio::io::split(stream);
8978        let mut writer = writer;
8979        let mut reader = reader;
8980
8981        let hs = Handshake::new(
8982            info_hash,
8983            Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
8984        );
8985        writer.write_all(&hs.to_bytes()).await.unwrap();
8986        writer.flush().await.unwrap();
8987        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8988        reader.read_exact(&mut hs_buf).await.unwrap();
8989
8990        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8991        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8992
8993        // Read ext handshake
8994        let _msg = framed_read.next().await;
8995        let ext_hs = ExtHandshake::new();
8996        let payload = ext_hs.to_bytes().unwrap();
8997        framed_write
8998            .send(Message::Extended { ext_id: 0, payload })
8999            .await
9000            .unwrap();
9001
9002        // Send bitfield saying we have all pieces (act as seeder)
9003        let mut bf = Bitfield::new(2);
9004        bf.set(0);
9005        bf.set(1);
9006        framed_write
9007            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
9008            .await
9009            .unwrap();
9010
9011        // Unchoke the torrent
9012        framed_write.send(Message::Unchoke).await.unwrap();
9013
9014        // Count Request messages received within 500ms.
9015        // With 1 KB/s download limit, the bucket only accumulates ~50 bytes
9016        // per 100ms tick, far less than 16 KB needed for a full chunk request.
9017        let mut requests_received = 0u32;
9018        let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
9019        loop {
9020            match tokio::time::timeout(
9021                deadline.saturating_duration_since(tokio::time::Instant::now()),
9022                framed_read.next(),
9023            )
9024            .await
9025            {
9026                Ok(Some(Ok(Message::Request { .. }))) => {
9027                    requests_received += 1;
9028                }
9029                Ok(Some(Ok(_))) => {}
9030                _ => break,
9031            }
9032        }
9033
9034        let stats = handle.stats().await.unwrap();
9035        assert_eq!(stats.state, TorrentState::Downloading);
9036
9037        // With 1 KB/s download limit and 16 KB chunks, we should see very few
9038        // or no requests within 500ms (budget insufficient for even one chunk)
9039        assert!(
9040            requests_received <= 2,
9041            "with 1 KB/s limit, should get very few requests, got {requests_received}"
9042        );
9043
9044        handle.shutdown().await.unwrap();
9045    }
9046
9047    // ── Smart banning tests (M25) ────────────────────────────────────
9048
9049    #[test]
9050    fn piece_contributor_tracking() {
9051        use std::net::IpAddr;
9052        let mut contributors: HashMap<u32, HashSet<IpAddr>> = HashMap::new();
9053        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9054        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9055
9056        contributors.entry(0).or_default().insert(ip1);
9057        contributors.entry(0).or_default().insert(ip2);
9058        assert_eq!(contributors[&0].len(), 2);
9059        assert!(contributors[&0].contains(&ip1));
9060        assert!(contributors[&0].contains(&ip2));
9061
9062        // Clear on verify
9063        contributors.remove(&0);
9064        assert!(!contributors.contains_key(&0));
9065    }
9066
9067    #[test]
9068    fn parole_enter_on_hash_failure() {
9069        use crate::ban::ParoleState;
9070        use std::net::IpAddr;
9071
9072        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9073        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9074        let contributors = vec![ip1, ip2];
9075
9076        // Simulate entering parole
9077        let parole = ParoleState {
9078            original_contributors: contributors.into_iter().collect(),
9079            parole_peer: None,
9080        };
9081
9082        assert_eq!(parole.original_contributors.len(), 2);
9083        assert!(parole.original_contributors.contains(&ip1));
9084        assert!(parole.original_contributors.contains(&ip2));
9085        assert!(parole.parole_peer.is_none());
9086    }
9087
9088    #[test]
9089    fn parole_success_strikes_originals() {
9090        use crate::ban::{BanConfig, BanManager, ParoleState};
9091        use std::net::IpAddr;
9092
9093        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9094        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9095        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9096
9097        let mut mgr = BanManager::new(BanConfig {
9098            max_failures: 2,
9099            use_parole: true,
9100        });
9101
9102        let parole = ParoleState {
9103            original_contributors: [ip1, ip2].into_iter().collect(),
9104            parole_peer: Some(parole_ip),
9105        };
9106
9107        // Simulate parole success: strike all originals
9108        for ip in &parole.original_contributors {
9109            mgr.record_strike(*ip);
9110        }
9111
9112        assert_eq!(*mgr.strikes_map().get(&ip1).unwrap(), 1);
9113        assert_eq!(*mgr.strikes_map().get(&ip2).unwrap(), 1);
9114        // Parole peer should not be struck
9115        assert!(!mgr.strikes_map().contains_key(&parole_ip));
9116
9117        // Second strike bans them
9118        for ip in &parole.original_contributors {
9119            mgr.record_strike(*ip);
9120        }
9121        assert!(mgr.is_banned(&ip1));
9122        assert!(mgr.is_banned(&ip2));
9123    }
9124
9125    #[test]
9126    fn parole_failure_strikes_parole_peer() {
9127        use crate::ban::{BanConfig, BanManager, ParoleState};
9128        use std::net::IpAddr;
9129
9130        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9131        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9132
9133        let mut mgr = BanManager::new(BanConfig {
9134            max_failures: 2,
9135            use_parole: true,
9136        });
9137
9138        let parole = ParoleState {
9139            original_contributors: [ip1].into_iter().collect(),
9140            parole_peer: Some(parole_ip),
9141        };
9142
9143        // Parole failure: strike the parole peer, not originals
9144        if let Some(pp) = parole.parole_peer {
9145            mgr.record_strike(pp);
9146        }
9147
9148        assert_eq!(*mgr.strikes_map().get(&parole_ip).unwrap(), 1);
9149        assert!(!mgr.strikes_map().contains_key(&ip1));
9150    }
9151
9152    #[tokio::test]
9153    async fn banned_peer_rejected_on_connect() {
9154        let data = vec![0xAB; 32768];
9155        let meta = make_test_torrent(&data, 16384);
9156        let storage = make_storage(&data, 16384);
9157        let config = test_config();
9158        let ban_mgr = test_ban_manager();
9159
9160        // Pre-ban an IP
9161        let banned_ip: std::net::IpAddr = "192.168.1.100".parse().unwrap();
9162        ban_mgr.write().ban(banned_ip);
9163
9164        let (atx, amask) = test_alert_channel();
9165        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9166        let handle = TorrentHandle::from_torrent(
9167            meta,
9168            irontide_core::TorrentVersion::V1Only,
9169            None,
9170            dh,
9171            dm,
9172            config,
9173            test_dht_rx(),
9174            test_dht_rx(),
9175            None,
9176            None,
9177            crate::slot_tuner::SlotTuner::disabled(4),
9178            atx,
9179            amask,
9180            None,
9181            None,
9182            Arc::clone(&ban_mgr),
9183            test_ip_filter(),
9184            Arc::new(Vec::new()),
9185            None,
9186            None,
9187            Arc::new(crate::transport::NetworkFactory::tokio()),
9188            None, // M96: hash_pool
9189            Arc::new(crate::stats::SessionCounters::new()),
9190        )
9191        .await
9192        .unwrap();
9193
9194        // Add the banned peer — it should be filtered out
9195        handle
9196            .add_peers(
9197                vec![
9198                    SocketAddr::new(banned_ip, 6881),
9199                    "10.0.0.1:6881".parse().unwrap(),
9200                ],
9201                PeerSource::Tracker,
9202            )
9203            .await
9204            .unwrap();
9205
9206        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9207        let stats = handle.stats().await.unwrap();
9208        // Only the non-banned peer should be in available pool (and may have connected)
9209        // The banned one should never appear
9210        assert!(
9211            stats.peers_available + stats.peers_connected <= 1,
9212            "banned peer should not be added: available={}, connected={}",
9213            stats.peers_available,
9214            stats.peers_connected
9215        );
9216
9217        handle.shutdown().await.unwrap();
9218    }
9219
9220    #[test]
9221    fn banned_peer_filtered_from_available() {
9222        use crate::ban::{BanConfig, BanManager};
9223        use std::net::IpAddr;
9224
9225        let banned_ip: IpAddr = "192.168.1.200".parse().unwrap();
9226        let ok_ip: IpAddr = "10.0.0.1".parse().unwrap();
9227
9228        let mgr = BanManager::new(BanConfig::default());
9229        // Not banned yet — both should pass
9230        assert!(!mgr.is_banned(&banned_ip));
9231        assert!(!mgr.is_banned(&ok_ip));
9232
9233        let mut mgr = BanManager::new(BanConfig::default());
9234        mgr.ban(banned_ip);
9235
9236        // Now banned_ip is filtered, ok_ip is not
9237        assert!(mgr.is_banned(&banned_ip));
9238        assert!(!mgr.is_banned(&ok_ip));
9239    }
9240
9241    // ---- M27: Parallel hashing tests ----
9242
9243    #[test]
9244    fn hashing_threads_config_default() {
9245        let s = irontide_settings::Settings::default();
9246        let expected = {
9247            let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
9248            (cores / 4).clamp(2, 8)
9249        };
9250        assert_eq!(s.hashing_threads, expected);
9251        let tc = TorrentConfig::default();
9252        assert_eq!(tc.hashing_threads, expected);
9253    }
9254
9255    #[tokio::test]
9256    async fn checking_state_and_progress_alerts() {
9257        use crate::alert::AlertKind;
9258
9259        let data = vec![0xEEu8; 65536]; // 4 pieces of 16384
9260        let meta = make_test_torrent(&data, 16384);
9261        let storage = make_seeded_storage(&data, 16384);
9262        let config = test_config();
9263
9264        let (atx, amask) = test_alert_channel();
9265        let mut rx = atx.subscribe();
9266        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9267        let handle = TorrentHandle::from_torrent(
9268            meta,
9269            irontide_core::TorrentVersion::V1Only,
9270            None,
9271            dh,
9272            dm,
9273            config,
9274            test_dht_rx(),
9275            test_dht_rx(),
9276            None,
9277            None,
9278            crate::slot_tuner::SlotTuner::disabled(4),
9279            atx,
9280            amask,
9281            None,
9282            None,
9283            test_ban_manager(),
9284            test_ip_filter(),
9285            Arc::new(Vec::new()),
9286            None,
9287            None,
9288            Arc::new(crate::transport::NetworkFactory::tokio()),
9289            None, // M96: hash_pool
9290            Arc::new(crate::stats::SessionCounters::new()),
9291        )
9292        .await
9293        .unwrap();
9294
9295        // Collect alerts for up to 2 seconds
9296        let mut saw_checking = false;
9297        let mut progress_values: Vec<f32> = Vec::new();
9298        let mut saw_checked = false;
9299        let mut checked_have = 0u32;
9300        let mut checked_total = 0u32;
9301
9302        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9303        while tokio::time::Instant::now() < deadline {
9304            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9305                Ok(Ok(alert)) => match alert.kind {
9306                    AlertKind::StateChanged {
9307                        new_state: TorrentState::Checking,
9308                        ..
9309                    } => {
9310                        saw_checking = true;
9311                    }
9312                    AlertKind::CheckingProgress { progress, .. } => {
9313                        progress_values.push(progress);
9314                    }
9315                    AlertKind::TorrentChecked {
9316                        pieces_have,
9317                        pieces_total,
9318                        ..
9319                    } => {
9320                        saw_checked = true;
9321                        checked_have = pieces_have;
9322                        checked_total = pieces_total;
9323                        break;
9324                    }
9325                    _ => {}
9326                },
9327                _ => break,
9328            }
9329        }
9330
9331        assert!(saw_checking, "should have seen StateChanged → Checking");
9332        assert!(
9333            !progress_values.is_empty(),
9334            "should have seen CheckingProgress alerts"
9335        );
9336        // Progress should be monotonically increasing
9337        for w in progress_values.windows(2) {
9338            assert!(
9339                w[1] >= w[0],
9340                "progress should be monotonically increasing: {} < {}",
9341                w[0],
9342                w[1]
9343            );
9344        }
9345        assert!(saw_checked, "should have seen TorrentChecked");
9346        assert_eq!(checked_have, 4);
9347        assert_eq!(checked_total, 4);
9348
9349        // Final state should be Seeding (all pieces valid)
9350        tokio::time::sleep(Duration::from_millis(50)).await;
9351        let stats = handle.stats().await.unwrap();
9352        assert_eq!(stats.state, TorrentState::Seeding);
9353
9354        handle.shutdown().await.unwrap();
9355    }
9356
9357    #[tokio::test]
9358    #[allow(clippy::float_cmp, reason = "exact sentinel value comparison (0.0)")]
9359    async fn checking_progress_in_stats() {
9360        // When not in Checking state, checking_progress should be 0.0
9361        let data = vec![0xAB; 32768];
9362        let meta = make_test_torrent(&data, 16384);
9363        let storage = make_storage(&data, 16384);
9364        let config = test_config();
9365
9366        let (atx, amask) = test_alert_channel();
9367        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9368        let handle = TorrentHandle::from_torrent(
9369            meta,
9370            irontide_core::TorrentVersion::V1Only,
9371            None,
9372            dh,
9373            dm,
9374            config,
9375            test_dht_rx(),
9376            test_dht_rx(),
9377            None,
9378            None,
9379            crate::slot_tuner::SlotTuner::disabled(4),
9380            atx,
9381            amask,
9382            None,
9383            None,
9384            test_ban_manager(),
9385            test_ip_filter(),
9386            Arc::new(Vec::new()),
9387            None,
9388            None,
9389            Arc::new(crate::transport::NetworkFactory::tokio()),
9390            None, // M96: hash_pool
9391            Arc::new(crate::stats::SessionCounters::new()),
9392        )
9393        .await
9394        .unwrap();
9395
9396        // Give actor time to finish checking (no valid pieces → Downloading)
9397        tokio::time::sleep(Duration::from_millis(100)).await;
9398
9399        let stats = handle.stats().await.unwrap();
9400        assert_eq!(stats.state, TorrentState::Downloading);
9401        assert_eq!(
9402            stats.checking_progress, 0.0,
9403            "checking_progress should be 0.0 when not checking"
9404        );
9405
9406        handle.shutdown().await.unwrap();
9407    }
9408
9409    #[tokio::test]
9410    async fn verify_pieces_partial_data() {
9411        use crate::alert::AlertKind;
9412
9413        // 4 pieces, only first 2 have valid data
9414        let data = vec![0xCCu8; 65536]; // 4 pieces × 16384
9415        let meta = make_test_torrent(&data, 16384);
9416
9417        // Create storage and only write valid data for pieces 0 and 1
9418        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9419        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
9420        for p in 0..2u32 {
9421            let offset = lengths.piece_offset(p) as usize;
9422            let size = lengths.piece_size(p) as usize;
9423            storage
9424                .write_chunk(p, 0, &data[offset..offset + size])
9425                .unwrap();
9426        }
9427        // Pieces 2 and 3 have no data (zeros) — won't match hash
9428
9429        let config = test_config();
9430        let (atx, amask) = test_alert_channel();
9431        let mut rx = atx.subscribe();
9432        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9433        let handle = TorrentHandle::from_torrent(
9434            meta,
9435            irontide_core::TorrentVersion::V1Only,
9436            None,
9437            dh,
9438            dm,
9439            config,
9440            test_dht_rx(),
9441            test_dht_rx(),
9442            None,
9443            None,
9444            crate::slot_tuner::SlotTuner::disabled(4),
9445            atx,
9446            amask,
9447            None,
9448            None,
9449            test_ban_manager(),
9450            test_ip_filter(),
9451            Arc::new(Vec::new()),
9452            None,
9453            None,
9454            Arc::new(crate::transport::NetworkFactory::tokio()),
9455            None, // M96: hash_pool
9456            Arc::new(crate::stats::SessionCounters::new()),
9457        )
9458        .await
9459        .unwrap();
9460
9461        // Wait for TorrentChecked alert
9462        let mut checked_have = 0u32;
9463        let mut checked_total = 0u32;
9464        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9465        while tokio::time::Instant::now() < deadline {
9466            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9467                Ok(Ok(alert)) => {
9468                    if let AlertKind::TorrentChecked {
9469                        pieces_have,
9470                        pieces_total,
9471                        ..
9472                    } = alert.kind
9473                    {
9474                        checked_have = pieces_have;
9475                        checked_total = pieces_total;
9476                        break;
9477                    }
9478                }
9479                _ => break,
9480            }
9481        }
9482
9483        assert_eq!(checked_have, 2, "only 2 pieces should be valid");
9484        assert_eq!(checked_total, 4);
9485
9486        // Final state should be Downloading (partial)
9487        tokio::time::sleep(Duration::from_millis(50)).await;
9488        let stats = handle.stats().await.unwrap();
9489        assert_eq!(stats.state, TorrentState::Downloading);
9490        assert_eq!(stats.pieces_have, 2);
9491        assert_eq!(stats.pieces_total, 4);
9492
9493        handle.shutdown().await.unwrap();
9494    }
9495
9496    // ---- M29: IP filter integration tests ----
9497
9498    #[tokio::test]
9499    async fn ip_filter_blocks_peers_in_handle_add_peers() {
9500        let data = vec![0xCD; 32768];
9501        let meta = make_test_torrent(&data, 16384);
9502        let storage = make_storage(&data, 16384);
9503        let config = test_config();
9504
9505        // Create an IP filter that blocks 203.0.113.0/24 (TEST-NET-3, public range)
9506        let ip_filter = {
9507            let mut f = crate::ip_filter::IpFilter::new();
9508            f.add_rule(
9509                "203.0.113.0".parse().unwrap(),
9510                "203.0.113.255".parse().unwrap(),
9511                1,
9512            );
9513            Arc::new(parking_lot::RwLock::new(f))
9514        };
9515
9516        let (atx, amask) = test_alert_channel();
9517        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9518        let handle = TorrentHandle::from_torrent(
9519            meta,
9520            irontide_core::TorrentVersion::V1Only,
9521            None,
9522            dh,
9523            dm,
9524            config,
9525            test_dht_rx(),
9526            test_dht_rx(),
9527            None,
9528            None,
9529            crate::slot_tuner::SlotTuner::disabled(4),
9530            atx,
9531            amask,
9532            None,
9533            None,
9534            test_ban_manager(),
9535            Arc::clone(&ip_filter),
9536            Arc::new(Vec::new()),
9537            None,
9538            None,
9539            Arc::new(crate::transport::NetworkFactory::tokio()),
9540            None, // M96: hash_pool
9541            Arc::new(crate::stats::SessionCounters::new()),
9542        )
9543        .await
9544        .unwrap();
9545
9546        // Add peers: one blocked (public IP in TEST-NET-3), one allowed (different public IP)
9547        let blocked_addr: SocketAddr = "203.0.113.42:6881".parse().unwrap();
9548        let allowed_addr: SocketAddr = "198.51.100.1:6881".parse().unwrap();
9549        handle
9550            .add_peers(vec![blocked_addr, allowed_addr], PeerSource::Tracker)
9551            .await
9552            .unwrap();
9553
9554        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9555        let stats = handle.stats().await.unwrap();
9556        // Only the allowed peer should be in the pool
9557        assert!(
9558            stats.peers_available + stats.peers_connected <= 1,
9559            "blocked peer should not be added: available={}, connected={}",
9560            stats.peers_available,
9561            stats.peers_connected
9562        );
9563
9564        handle.shutdown().await.unwrap();
9565    }
9566
9567    #[tokio::test]
9568    async fn set_ip_filter_replaces_filter_and_blocks_new_ip() {
9569        // Test that updating the shared IP filter takes effect for new peer additions.
9570        // Use public IPs (TEST-NET ranges) since local networks are always exempt.
9571        let data = vec![0xCD; 32768];
9572        let meta = make_test_torrent(&data, 16384);
9573        let storage = make_storage(&data, 16384);
9574        let config = test_config();
9575
9576        // Start with empty filter (everything allowed)
9577        let ip_filter: irontide_session_types::SharedIpFilter =
9578            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
9579
9580        let (atx, amask) = test_alert_channel();
9581        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9582        let handle = TorrentHandle::from_torrent(
9583            meta,
9584            irontide_core::TorrentVersion::V1Only,
9585            None,
9586            dh,
9587            dm,
9588            config,
9589            test_dht_rx(),
9590            test_dht_rx(),
9591            None,
9592            None,
9593            crate::slot_tuner::SlotTuner::disabled(4),
9594            atx,
9595            amask,
9596            None,
9597            None,
9598            test_ban_manager(),
9599            Arc::clone(&ip_filter),
9600            Arc::new(Vec::new()),
9601            None,
9602            None,
9603            Arc::new(crate::transport::NetworkFactory::tokio()),
9604            None, // M96: hash_pool
9605            Arc::new(crate::stats::SessionCounters::new()),
9606        )
9607        .await
9608        .unwrap();
9609
9610        // Initially, peers are allowed by the IP filter.
9611        // Use a local listener so the connection succeeds and the peer stays known.
9612        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9613        let local_addr = listener.local_addr().unwrap();
9614        handle
9615            .add_peers(vec![local_addr], PeerSource::Tracker)
9616            .await
9617            .unwrap();
9618        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9619        let stats = handle.stats().await.unwrap();
9620        assert!(
9621            stats.peers_available + stats.peers_connected >= 1,
9622            "peer should be allowed initially"
9623        );
9624        handle.shutdown().await.unwrap();
9625
9626        // Now update the shared filter to block that IP range
9627        {
9628            let mut f = ip_filter.write();
9629            f.add_rule(
9630                "198.51.100.0".parse().unwrap(),
9631                "198.51.100.255".parse().unwrap(),
9632                1,
9633            );
9634        }
9635
9636        // Verify the filter is updated (public IP, so is_blocked applies)
9637        assert!(ip_filter.read().is_blocked("198.51.100.1".parse().unwrap()));
9638        // Verify a different public IP is still allowed
9639        assert!(!ip_filter.read().is_blocked("203.0.113.1".parse().unwrap()));
9640    }
9641
9642    #[test]
9643    fn relocate_files_moves_and_cleans_up() {
9644        let tmp = std::env::temp_dir().join(format!("torrent_relocate_{}", std::process::id()));
9645        let src = tmp.join("src");
9646        let dst = tmp.join("dst");
9647
9648        // Create source files mimicking multi-file torrent layout:
9649        // TorrentName/subdir/file1.txt
9650        // TorrentName/file2.txt
9651        let subdir = src.join("TorrentName").join("subdir");
9652        std::fs::create_dir_all(&subdir).unwrap();
9653        std::fs::write(subdir.join("file1.txt"), b"hello").unwrap();
9654        std::fs::write(src.join("TorrentName").join("file2.txt"), b"world").unwrap();
9655
9656        let file_paths = vec![
9657            std::path::PathBuf::from("TorrentName/subdir/file1.txt"),
9658            std::path::PathBuf::from("TorrentName/file2.txt"),
9659        ];
9660
9661        relocate_files(&src, &dst, &file_paths).unwrap();
9662
9663        // Destination should have both files
9664        assert_eq!(
9665            std::fs::read_to_string(dst.join("TorrentName/subdir/file1.txt")).unwrap(),
9666            "hello"
9667        );
9668        assert_eq!(
9669            std::fs::read_to_string(dst.join("TorrentName/file2.txt")).unwrap(),
9670            "world"
9671        );
9672
9673        // Source directory should be cleaned up (empty dirs removed)
9674        assert!(!src.join("TorrentName").join("subdir").exists());
9675        assert!(!src.join("TorrentName").exists());
9676
9677        // Cleanup
9678        let _ = std::fs::remove_dir_all(&tmp);
9679    }
9680
9681    #[test]
9682    fn relocate_files_skips_missing() {
9683        let tmp =
9684            std::env::temp_dir().join(format!("torrent_relocate_skip_{}", std::process::id()));
9685        let src = tmp.join("src");
9686        let dst = tmp.join("dst");
9687        std::fs::create_dir_all(&src).unwrap();
9688
9689        // File doesn't exist — should be skipped without error
9690        let file_paths = vec![std::path::PathBuf::from("nonexistent.txt")];
9691        relocate_files(&src, &dst, &file_paths).unwrap();
9692
9693        assert!(!dst.join("nonexistent.txt").exists());
9694
9695        let _ = std::fs::remove_dir_all(&tmp);
9696    }
9697
9698    // ---- Test: force_recheck transitions through Checking state ----
9699
9700    #[tokio::test]
9701    async fn force_recheck_transitions_to_checking() {
9702        let data = vec![0xDDu8; 32768]; // 2 pieces
9703        let meta = make_test_torrent(&data, 16384);
9704        let storage = make_seeded_storage(&data, 16384);
9705        let config = test_config();
9706
9707        let (atx, amask) = test_alert_channel();
9708        let mut arx = atx.subscribe();
9709        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9710        let handle = TorrentHandle::from_torrent(
9711            meta,
9712            irontide_core::TorrentVersion::V1Only,
9713            None,
9714            dh,
9715            dm,
9716            config,
9717            test_dht_rx(),
9718            test_dht_rx(),
9719            None,
9720            None,
9721            crate::slot_tuner::SlotTuner::disabled(4),
9722            atx,
9723            amask,
9724            None,
9725            None,
9726            test_ban_manager(),
9727            test_ip_filter(),
9728            Arc::new(Vec::new()),
9729            None,
9730            None,
9731            Arc::new(crate::transport::NetworkFactory::tokio()),
9732            None, // M96: hash_pool
9733            Arc::new(crate::stats::SessionCounters::new()),
9734        )
9735        .await
9736        .unwrap();
9737
9738        // Wait for initial verification to complete (should become Seeding)
9739        tokio::time::sleep(Duration::from_millis(100)).await;
9740        let stats = handle.stats().await.unwrap();
9741        assert_eq!(stats.state, TorrentState::Seeding, "should start as seeder");
9742
9743        // Drain any existing alerts
9744        while arx.try_recv().is_ok() {}
9745
9746        // Force recheck
9747        handle.force_recheck().await.unwrap();
9748
9749        // After force_recheck returns, look for a StateChanged alert that
9750        // went through Checking (the transition_state fires it)
9751        let mut saw_checking = false;
9752        while let Ok(alert) = arx.try_recv() {
9753            if let crate::alert::AlertKind::StateChanged { new_state, .. } = alert.kind
9754                && new_state == TorrentState::Checking
9755            {
9756                saw_checking = true;
9757            }
9758        }
9759        assert!(
9760            saw_checking,
9761            "should have transitioned through Checking state"
9762        );
9763
9764        handle.shutdown().await.unwrap();
9765    }
9766
9767    // ---- Test: force_recheck completes with correct state ----
9768
9769    #[tokio::test]
9770    async fn force_recheck_completes() {
9771        let data = vec![0xEEu8; 32768]; // 2 pieces
9772        let meta = make_test_torrent(&data, 16384);
9773        let storage = make_seeded_storage(&data, 16384);
9774        let config = test_config();
9775
9776        let (atx, amask) = test_alert_channel();
9777        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9778        let handle = TorrentHandle::from_torrent(
9779            meta,
9780            irontide_core::TorrentVersion::V1Only,
9781            None,
9782            dh,
9783            dm,
9784            config,
9785            test_dht_rx(),
9786            test_dht_rx(),
9787            None,
9788            None,
9789            crate::slot_tuner::SlotTuner::disabled(4),
9790            atx,
9791            amask,
9792            None,
9793            None,
9794            test_ban_manager(),
9795            test_ip_filter(),
9796            Arc::new(Vec::new()),
9797            None,
9798            None,
9799            Arc::new(crate::transport::NetworkFactory::tokio()),
9800            None, // M96: hash_pool
9801            Arc::new(crate::stats::SessionCounters::new()),
9802        )
9803        .await
9804        .unwrap();
9805
9806        // Wait for initial verification
9807        tokio::time::sleep(Duration::from_millis(100)).await;
9808        let stats = handle.stats().await.unwrap();
9809        assert_eq!(stats.state, TorrentState::Seeding);
9810        assert_eq!(stats.pieces_have, 2);
9811
9812        // Force recheck — should re-verify all pieces and return to Seeding
9813        handle.force_recheck().await.unwrap();
9814
9815        let stats = handle.stats().await.unwrap();
9816        assert_eq!(
9817            stats.state,
9818            TorrentState::Seeding,
9819            "should return to Seeding after recheck"
9820        );
9821        assert_eq!(stats.pieces_have, 2, "all pieces should still be verified");
9822
9823        handle.shutdown().await.unwrap();
9824    }
9825
9826    // ---- Test: rename_file succeeds with valid index ----
9827
9828    #[tokio::test]
9829    async fn rename_file_succeeds() {
9830        // Create a real file on disk that we can rename
9831        let tmp = std::env::temp_dir().join(format!("torrent_rename_{}", std::process::id()));
9832        std::fs::create_dir_all(&tmp).unwrap();
9833
9834        let data = vec![0xFFu8; 16384]; // 1 piece
9835        let meta = make_test_torrent(&data, 16384);
9836        let storage = make_seeded_storage(&data, 16384);
9837
9838        // The single-file torrent has name "test", so file path is "test"
9839        // Create the actual file on disk at download_dir/test
9840        std::fs::write(tmp.join("test"), &data).unwrap();
9841
9842        let mut config = test_config();
9843        config.download_dir = tmp.clone();
9844
9845        let (atx, amask) = test_alert_channel();
9846        let mut arx = atx.subscribe();
9847        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9848        let handle = TorrentHandle::from_torrent(
9849            meta,
9850            irontide_core::TorrentVersion::V1Only,
9851            None,
9852            dh,
9853            dm,
9854            config,
9855            test_dht_rx(),
9856            test_dht_rx(),
9857            None,
9858            None,
9859            crate::slot_tuner::SlotTuner::disabled(4),
9860            atx,
9861            amask,
9862            None,
9863            None,
9864            test_ban_manager(),
9865            test_ip_filter(),
9866            Arc::new(Vec::new()),
9867            None,
9868            None,
9869            Arc::new(crate::transport::NetworkFactory::tokio()),
9870            None, // M96: hash_pool
9871            Arc::new(crate::stats::SessionCounters::new()),
9872        )
9873        .await
9874        .unwrap();
9875
9876        // Wait for initial verification
9877        tokio::time::sleep(Duration::from_millis(100)).await;
9878
9879        // Drain existing alerts
9880        while arx.try_recv().is_ok() {}
9881
9882        // Rename file 0 to "test_renamed"
9883        handle.rename_file(0, "test_renamed".into()).await.unwrap();
9884
9885        // Check that the old file is gone and new file exists
9886        assert!(!tmp.join("test").exists(), "old file should be removed");
9887        assert!(tmp.join("test_renamed").exists(), "new file should exist");
9888
9889        // Check that FileRenamed alert was fired
9890        let mut saw_renamed = false;
9891        while let Ok(alert) = arx.try_recv() {
9892            if let AlertKind::FileRenamed { index, .. } = alert.kind {
9893                assert_eq!(index, 0);
9894                saw_renamed = true;
9895            }
9896        }
9897        assert!(saw_renamed, "should have received FileRenamed alert");
9898
9899        handle.shutdown().await.unwrap();
9900        let _ = std::fs::remove_dir_all(&tmp);
9901    }
9902
9903    // ---- Test: rename_file with invalid index returns error ----
9904
9905    #[tokio::test]
9906    async fn rename_file_invalid_index_errors() {
9907        let data = vec![0xCCu8; 16384]; // 1 piece, single-file torrent
9908        let meta = make_test_torrent(&data, 16384);
9909        let storage = make_seeded_storage(&data, 16384);
9910        let config = test_config();
9911
9912        let (atx, amask) = test_alert_channel();
9913        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9914        let handle = TorrentHandle::from_torrent(
9915            meta,
9916            irontide_core::TorrentVersion::V1Only,
9917            None,
9918            dh,
9919            dm,
9920            config,
9921            test_dht_rx(),
9922            test_dht_rx(),
9923            None,
9924            None,
9925            crate::slot_tuner::SlotTuner::disabled(4),
9926            atx,
9927            amask,
9928            None,
9929            None,
9930            test_ban_manager(),
9931            test_ip_filter(),
9932            Arc::new(Vec::new()),
9933            None,
9934            None,
9935            Arc::new(crate::transport::NetworkFactory::tokio()),
9936            None, // M96: hash_pool
9937            Arc::new(crate::stats::SessionCounters::new()),
9938        )
9939        .await
9940        .unwrap();
9941
9942        // Wait for initial verification
9943        tokio::time::sleep(Duration::from_millis(100)).await;
9944
9945        // Try to rename file index 99 (out of range)
9946        let result = handle.rename_file(99, "bad".into()).await;
9947        assert!(result.is_err(), "should fail for out-of-range file index");
9948
9949        handle.shutdown().await.unwrap();
9950    }
9951
9952    // ---- Test: FileCompleted alert fires when all pieces of a file are verified ----
9953
9954    #[tokio::test]
9955    async fn file_completed_alert_fires() {
9956        let data = vec![0xBBu8; 32768]; // 2 pieces
9957        let meta = make_test_torrent(&data, 16384);
9958        let storage = make_seeded_storage(&data, 16384);
9959        let config = test_config();
9960
9961        let (atx, amask) = test_alert_channel();
9962        let mut arx = atx.subscribe();
9963        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9964        let handle = TorrentHandle::from_torrent(
9965            meta,
9966            irontide_core::TorrentVersion::V1Only,
9967            None,
9968            dh,
9969            dm,
9970            config,
9971            test_dht_rx(),
9972            test_dht_rx(),
9973            None,
9974            None,
9975            crate::slot_tuner::SlotTuner::disabled(4),
9976            atx,
9977            amask,
9978            None,
9979            None,
9980            test_ban_manager(),
9981            test_ip_filter(),
9982            Arc::new(Vec::new()),
9983            None,
9984            None,
9985            Arc::new(crate::transport::NetworkFactory::tokio()),
9986            None, // M96: hash_pool
9987            Arc::new(crate::stats::SessionCounters::new()),
9988        )
9989        .await
9990        .unwrap();
9991
9992        // Wait for initial verification (seeded storage => all pieces verify)
9993        tokio::time::sleep(Duration::from_millis(200)).await;
9994
9995        // Should have received FileCompleted alert for the single file
9996        let mut saw_file_completed = false;
9997        while let Ok(alert) = arx.try_recv() {
9998            if let AlertKind::FileCompleted { file_index, .. } = alert.kind {
9999                assert_eq!(file_index, 0, "should be file index 0");
10000                saw_file_completed = true;
10001            }
10002        }
10003        assert!(
10004            saw_file_completed,
10005            "should have received FileCompleted alert"
10006        );
10007
10008        handle.shutdown().await.unwrap();
10009    }
10010
10011    // ---- Test: MetadataFailed alert fires (unit test on AlertKind) ----
10012
10013    #[test]
10014    fn metadata_failed_alert_fires() {
10015        // Test that MetadataFailed alert has the correct category
10016        let info_hash = Id20::from([0u8; 20]);
10017        let alert = crate::alert::Alert::new(AlertKind::MetadataFailed { info_hash });
10018        assert!(
10019            alert
10020                .category()
10021                .contains(crate::alert::AlertCategory::STATUS),
10022            "MetadataFailed should have STATUS category"
10023        );
10024        assert!(
10025            alert
10026                .category()
10027                .contains(crate::alert::AlertCategory::ERROR),
10028            "MetadataFailed should have ERROR category"
10029        );
10030
10031        // Verify it can be posted through the alert system
10032        let (tx, mut rx) = broadcast::channel(16);
10033        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
10034        post_alert(&tx, &mask, AlertKind::MetadataFailed { info_hash });
10035        let received = rx.try_recv().expect("should receive MetadataFailed alert");
10036        assert!(matches!(received.kind, AlertKind::MetadataFailed { .. }));
10037    }
10038
10039    // ---- Test: set_max_connections persists ----
10040
10041    #[tokio::test]
10042    async fn set_max_connections_persists() {
10043        let data = vec![0xAB; 32768];
10044        let meta = make_test_torrent(&data, 16384);
10045        let storage = make_storage(&data, 16384);
10046        let config = test_config();
10047
10048        let (atx, amask) = test_alert_channel();
10049        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10050        let handle = TorrentHandle::from_torrent(
10051            meta,
10052            irontide_core::TorrentVersion::V1Only,
10053            None,
10054            dh,
10055            dm,
10056            config,
10057            test_dht_rx(),
10058            test_dht_rx(),
10059            None,
10060            None,
10061            crate::slot_tuner::SlotTuner::disabled(4),
10062            atx,
10063            amask,
10064            None,
10065            None,
10066            test_ban_manager(),
10067            test_ip_filter(),
10068            Arc::new(Vec::new()),
10069            None,
10070            None,
10071            Arc::new(crate::transport::NetworkFactory::tokio()),
10072            None, // M96: hash_pool
10073            Arc::new(crate::stats::SessionCounters::new()),
10074        )
10075        .await
10076        .unwrap();
10077
10078        // Set max_connections to 10
10079        handle.set_max_connections(10).await.unwrap();
10080        let val = handle.max_connections().await.unwrap();
10081        assert_eq!(val, 10);
10082
10083        // Update to a different value
10084        handle.set_max_connections(25).await.unwrap();
10085        let val = handle.max_connections().await.unwrap();
10086        assert_eq!(val, 25);
10087
10088        // Verify stats reflect the override
10089        let stats = handle.stats().await.unwrap();
10090        assert_eq!(stats.connections_limit, 25);
10091
10092        handle.shutdown().await.unwrap();
10093    }
10094
10095    // ---- Test: max_connections default is 0 (use config.max_peers) ----
10096
10097    #[tokio::test]
10098    async fn max_connections_default() {
10099        let data = vec![0xAB; 32768];
10100        let meta = make_test_torrent(&data, 16384);
10101        let storage = make_storage(&data, 16384);
10102        let config = test_config();
10103        let expected_default = config.max_peers;
10104
10105        let (atx, amask) = test_alert_channel();
10106        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10107        let handle = TorrentHandle::from_torrent(
10108            meta,
10109            irontide_core::TorrentVersion::V1Only,
10110            None,
10111            dh,
10112            dm,
10113            config,
10114            test_dht_rx(),
10115            test_dht_rx(),
10116            None,
10117            None,
10118            crate::slot_tuner::SlotTuner::disabled(4),
10119            atx,
10120            amask,
10121            None,
10122            None,
10123            test_ban_manager(),
10124            test_ip_filter(),
10125            Arc::new(Vec::new()),
10126            None,
10127            None,
10128            Arc::new(crate::transport::NetworkFactory::tokio()),
10129            None, // M96: hash_pool
10130            Arc::new(crate::stats::SessionCounters::new()),
10131        )
10132        .await
10133        .unwrap();
10134
10135        // Default max_connections should be 0
10136        let val = handle.max_connections().await.unwrap();
10137        assert_eq!(val, 0);
10138
10139        // Stats should show config.max_peers as the effective limit
10140        let stats = handle.stats().await.unwrap();
10141        assert_eq!(stats.connections_limit, expected_default);
10142
10143        handle.shutdown().await.unwrap();
10144    }
10145
10146    // ---- Test: set_max_uploads round trip ----
10147
10148    #[tokio::test]
10149    async fn set_max_uploads_round_trip() {
10150        let data = vec![0xAB; 32768];
10151        let meta = make_test_torrent(&data, 16384);
10152        let storage = make_storage(&data, 16384);
10153        let config = test_config();
10154
10155        let (atx, amask) = test_alert_channel();
10156        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10157        let handle = TorrentHandle::from_torrent(
10158            meta,
10159            irontide_core::TorrentVersion::V1Only,
10160            None,
10161            dh,
10162            dm,
10163            config,
10164            test_dht_rx(),
10165            test_dht_rx(),
10166            None,
10167            None,
10168            crate::slot_tuner::SlotTuner::disabled(4),
10169            atx,
10170            amask,
10171            None,
10172            None,
10173            test_ban_manager(),
10174            test_ip_filter(),
10175            Arc::new(Vec::new()),
10176            None,
10177            None,
10178            Arc::new(crate::transport::NetworkFactory::tokio()),
10179            None, // M96: hash_pool
10180            Arc::new(crate::stats::SessionCounters::new()),
10181        )
10182        .await
10183        .unwrap();
10184
10185        // Set max_uploads to 8
10186        handle.set_max_uploads(8).await.unwrap();
10187        let val = handle.max_uploads().await.unwrap();
10188        assert_eq!(val, 8);
10189
10190        // Verify stats uploads_limit reflects the new value
10191        let stats = handle.stats().await.unwrap();
10192        assert_eq!(stats.uploads_limit, 8);
10193
10194        handle.shutdown().await.unwrap();
10195    }
10196
10197    // ---- Test: ExternalIpDetected alert fires ----
10198
10199    #[tokio::test]
10200    async fn external_ip_detected_alert() {
10201        let data = vec![0xAB; 32768];
10202        let meta = make_test_torrent(&data, 16384);
10203        let storage = make_storage(&data, 16384);
10204        let config = test_config();
10205
10206        let (atx, amask) = test_alert_channel();
10207        let mut arx = atx.subscribe();
10208        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10209        let handle = TorrentHandle::from_torrent(
10210            meta,
10211            irontide_core::TorrentVersion::V1Only,
10212            None,
10213            dh,
10214            dm,
10215            config,
10216            test_dht_rx(),
10217            test_dht_rx(),
10218            None,
10219            None,
10220            crate::slot_tuner::SlotTuner::disabled(4),
10221            atx,
10222            amask,
10223            None,
10224            None,
10225            test_ban_manager(),
10226            test_ip_filter(),
10227            Arc::new(Vec::new()),
10228            None,
10229            None,
10230            Arc::new(crate::transport::NetworkFactory::tokio()),
10231            None, // M96: hash_pool
10232            Arc::new(crate::stats::SessionCounters::new()),
10233        )
10234        .await
10235        .unwrap();
10236
10237        // Drain any initial alerts
10238        while arx.try_recv().is_ok() {}
10239
10240        // Send UpdateExternalIp command
10241        let test_ip: std::net::IpAddr = "203.0.113.42".parse().unwrap();
10242        handle
10243            .cmd_tx
10244            .send(TorrentCommand::UpdateExternalIp { ip: test_ip })
10245            .await
10246            .unwrap();
10247
10248        // Wait for the actor to process
10249        tokio::time::sleep(Duration::from_millis(50)).await;
10250
10251        // Check for ExternalIpDetected alert
10252        let mut saw_alert = false;
10253        while let Ok(alert) = arx.try_recv() {
10254            if let AlertKind::ExternalIpDetected { ip } = alert.kind {
10255                assert_eq!(ip, test_ip);
10256                saw_alert = true;
10257            }
10258        }
10259        assert!(saw_alert, "should have received ExternalIpDetected alert");
10260
10261        handle.shutdown().await.unwrap();
10262    }
10263
10264    // ---- Test: get_peer_info returns connected peers ----
10265
10266    #[tokio::test]
10267    async fn get_peer_info_returns_connected_peers() {
10268        let data = vec![0xAB; 65536]; // 64 KiB
10269        let meta = make_test_torrent(&data, 16384); // 4 pieces
10270        let storage = make_storage(&data, 16384);
10271        let config = test_config();
10272
10273        let (atx, amask) = test_alert_channel();
10274        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10275        let handle = TorrentHandle::from_torrent(
10276            meta.clone(),
10277            irontide_core::TorrentVersion::V1Only,
10278            None,
10279            dh,
10280            dm,
10281            config,
10282            test_dht_rx(),
10283            test_dht_rx(),
10284            None,
10285            None,
10286            crate::slot_tuner::SlotTuner::disabled(4),
10287            atx,
10288            amask,
10289            None,
10290            None,
10291            test_ban_manager(),
10292            test_ip_filter(),
10293            Arc::new(Vec::new()),
10294            None,
10295            None,
10296            Arc::new(crate::transport::NetworkFactory::tokio()),
10297            None, // M96: hash_pool
10298            Arc::new(crate::stats::SessionCounters::new()),
10299        )
10300        .await
10301        .unwrap();
10302
10303        // Set up a fake peer via TCP handshake
10304        let stats = handle.stats().await.unwrap();
10305        let listen_port = stats.peers_connected; // Initially 0
10306
10307        // Add a peer to the available pool and let the actor connect
10308        let peer_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
10309        let peer_addr = peer_listener.local_addr().unwrap();
10310
10311        handle
10312            .add_peers(vec![peer_addr], PeerSource::Tracker)
10313            .await
10314            .unwrap();
10315
10316        // Accept the connection and complete the handshake
10317        let accept_timeout =
10318            tokio::time::timeout(Duration::from_secs(2), peer_listener.accept()).await;
10319        if let Ok(Ok((mut stream, _))) = accept_timeout {
10320            // Read handshake
10321            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
10322            if tokio::time::timeout(Duration::from_millis(500), stream.read_exact(&mut hs_buf))
10323                .await
10324                .is_ok()
10325            {
10326                // Send back handshake
10327                let hs = Handshake::new(meta.info_hash, Id20::from([0xBB; 20]));
10328                let hs_bytes = hs.to_bytes();
10329                let _ = stream.write_all(&hs_bytes).await;
10330
10331                // Give the actor time to register the peer
10332                tokio::time::sleep(Duration::from_millis(200)).await;
10333
10334                // Now query peer info
10335                let peer_info = handle.get_peer_info().await.unwrap();
10336                // We should have at least one peer (the one we just handshaked)
10337                if !peer_info.is_empty() {
10338                    let p = &peer_info[0];
10339                    // Verify default choking/interested state
10340                    assert!(p.peer_choking, "peer should be choking us initially");
10341                    // M107: we unconditionally unchoke on connect, so am_choking starts false
10342                    assert!(
10343                        !p.am_choking,
10344                        "we should not be choking peer after connect (M107 unconditional unchoke)"
10345                    );
10346                    assert!(
10347                        !p.peer_interested,
10348                        "peer should not be interested initially"
10349                    );
10350                    assert_eq!(p.num_pieces, 0);
10351                    assert_eq!(p.source, PeerSource::Tracker);
10352                }
10353            }
10354        }
10355        // Even if handshake timing fails, at least verify the API works
10356        let _ = handle.get_peer_info().await.unwrap();
10357        assert_eq!(listen_port, 0); // sanity: initially had no peers
10358
10359        handle.shutdown().await.unwrap();
10360    }
10361
10362    // ---- Test: get_peer_info empty when no peers ----
10363
10364    #[tokio::test]
10365    async fn get_peer_info_empty_when_no_peers() {
10366        let data = vec![0xAB; 32768];
10367        let meta = make_test_torrent(&data, 16384);
10368        let storage = make_storage(&data, 16384);
10369        let config = test_config();
10370
10371        let (atx, amask) = test_alert_channel();
10372        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10373        let handle = TorrentHandle::from_torrent(
10374            meta,
10375            irontide_core::TorrentVersion::V1Only,
10376            None,
10377            dh,
10378            dm,
10379            config,
10380            test_dht_rx(),
10381            test_dht_rx(),
10382            None,
10383            None,
10384            crate::slot_tuner::SlotTuner::disabled(4),
10385            atx,
10386            amask,
10387            None,
10388            None,
10389            test_ban_manager(),
10390            test_ip_filter(),
10391            Arc::new(Vec::new()),
10392            None,
10393            None,
10394            Arc::new(crate::transport::NetworkFactory::tokio()),
10395            None, // M96: hash_pool
10396            Arc::new(crate::stats::SessionCounters::new()),
10397        )
10398        .await
10399        .unwrap();
10400
10401        let peer_info = handle.get_peer_info().await.unwrap();
10402        assert!(peer_info.is_empty(), "should have no peers initially");
10403
10404        handle.shutdown().await.unwrap();
10405    }
10406
10407    // ---- Test: get_download_queue empty initially ----
10408
10409    #[tokio::test]
10410    async fn get_download_queue_empty_initially() {
10411        let data = vec![0xAB; 32768];
10412        let meta = make_test_torrent(&data, 16384);
10413        let storage = make_storage(&data, 16384);
10414        let config = test_config();
10415
10416        let (atx, amask) = test_alert_channel();
10417        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10418        let handle = TorrentHandle::from_torrent(
10419            meta,
10420            irontide_core::TorrentVersion::V1Only,
10421            None,
10422            dh,
10423            dm,
10424            config,
10425            test_dht_rx(),
10426            test_dht_rx(),
10427            None,
10428            None,
10429            crate::slot_tuner::SlotTuner::disabled(4),
10430            atx,
10431            amask,
10432            None,
10433            None,
10434            test_ban_manager(),
10435            test_ip_filter(),
10436            Arc::new(Vec::new()),
10437            None,
10438            None,
10439            Arc::new(crate::transport::NetworkFactory::tokio()),
10440            None, // M96: hash_pool
10441            Arc::new(crate::stats::SessionCounters::new()),
10442        )
10443        .await
10444        .unwrap();
10445
10446        let queue = handle.get_download_queue().await.unwrap();
10447        assert!(
10448            queue.is_empty(),
10449            "download queue should be empty with no active downloads"
10450        );
10451
10452        handle.shutdown().await.unwrap();
10453    }
10454
10455    // ---- Test: have_piece false initially ----
10456
10457    #[tokio::test]
10458    async fn have_piece_false_initially() {
10459        let data = vec![0xAB; 32768]; // 32 KiB = 2 pieces
10460        let meta = make_test_torrent(&data, 16384);
10461        let storage = make_storage(&data, 16384);
10462        let config = test_config();
10463
10464        let (atx, amask) = test_alert_channel();
10465        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10466        let handle = TorrentHandle::from_torrent(
10467            meta,
10468            irontide_core::TorrentVersion::V1Only,
10469            None,
10470            dh,
10471            dm,
10472            config,
10473            test_dht_rx(),
10474            test_dht_rx(),
10475            None,
10476            None,
10477            crate::slot_tuner::SlotTuner::disabled(4),
10478            atx,
10479            amask,
10480            None,
10481            None,
10482            test_ban_manager(),
10483            test_ip_filter(),
10484            Arc::new(Vec::new()),
10485            None,
10486            None,
10487            Arc::new(crate::transport::NetworkFactory::tokio()),
10488            None, // M96: hash_pool
10489            Arc::new(crate::stats::SessionCounters::new()),
10490        )
10491        .await
10492        .unwrap();
10493
10494        assert!(
10495            !handle.have_piece(0).await.unwrap(),
10496            "piece 0 should not be downloaded initially"
10497        );
10498        assert!(
10499            !handle.have_piece(1).await.unwrap(),
10500            "piece 1 should not be downloaded initially"
10501        );
10502
10503        handle.shutdown().await.unwrap();
10504    }
10505
10506    // ---- Test: piece_availability empty with no peers ----
10507
10508    #[tokio::test]
10509    async fn piece_availability_empty_no_peers() {
10510        let data = vec![0xAB; 32768]; // 2 pieces
10511        let meta = make_test_torrent(&data, 16384);
10512        let storage = make_storage(&data, 16384);
10513        let config = test_config();
10514
10515        let (atx, amask) = test_alert_channel();
10516        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10517        let handle = TorrentHandle::from_torrent(
10518            meta,
10519            irontide_core::TorrentVersion::V1Only,
10520            None,
10521            dh,
10522            dm,
10523            config,
10524            test_dht_rx(),
10525            test_dht_rx(),
10526            None,
10527            None,
10528            crate::slot_tuner::SlotTuner::disabled(4),
10529            atx,
10530            amask,
10531            None,
10532            None,
10533            test_ban_manager(),
10534            test_ip_filter(),
10535            Arc::new(Vec::new()),
10536            None,
10537            None,
10538            Arc::new(crate::transport::NetworkFactory::tokio()),
10539            None, // M96: hash_pool
10540            Arc::new(crate::stats::SessionCounters::new()),
10541        )
10542        .await
10543        .unwrap();
10544
10545        let avail = handle.piece_availability().await.unwrap();
10546        assert_eq!(avail.len(), 2, "should have availability for 2 pieces");
10547        assert!(
10548            avail.iter().all(|&c| c == 0),
10549            "all availability counts should be 0 with no peers"
10550        );
10551
10552        handle.shutdown().await.unwrap();
10553    }
10554
10555    // ---- Test: file_progress zeros initially ----
10556
10557    #[tokio::test]
10558    async fn file_progress_zeros_initially() {
10559        let data = vec![0xAB; 32768]; // single-file, 2 pieces
10560        let meta = make_test_torrent(&data, 16384);
10561        let storage = make_storage(&data, 16384);
10562        let config = test_config();
10563
10564        let (atx, amask) = test_alert_channel();
10565        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10566        let handle = TorrentHandle::from_torrent(
10567            meta,
10568            irontide_core::TorrentVersion::V1Only,
10569            None,
10570            dh,
10571            dm,
10572            config,
10573            test_dht_rx(),
10574            test_dht_rx(),
10575            None,
10576            None,
10577            crate::slot_tuner::SlotTuner::disabled(4),
10578            atx,
10579            amask,
10580            None,
10581            None,
10582            test_ban_manager(),
10583            test_ip_filter(),
10584            Arc::new(Vec::new()),
10585            None,
10586            None,
10587            Arc::new(crate::transport::NetworkFactory::tokio()),
10588            None, // M96: hash_pool
10589            Arc::new(crate::stats::SessionCounters::new()),
10590        )
10591        .await
10592        .unwrap();
10593
10594        let progress = handle.file_progress().await.unwrap();
10595        assert_eq!(progress.len(), 1, "single-file torrent should have 1 entry");
10596        assert_eq!(progress[0], 0, "no bytes should be downloaded initially");
10597
10598        handle.shutdown().await.unwrap();
10599    }
10600
10601    // ---- Test: file_progress length matches file count (multi-file) ----
10602
10603    /// Build a multi-file `TorrentMetaV1` from a total data blob and file lengths.
10604    fn make_test_torrent_multi(
10605        data: &[u8],
10606        piece_length: u64,
10607        file_lengths: &[u64],
10608    ) -> TorrentMetaV1 {
10609        use serde::Serialize;
10610
10611        #[derive(Serialize)]
10612        struct FileE {
10613            length: u64,
10614            path: Vec<String>,
10615        }
10616
10617        #[derive(Serialize)]
10618        struct Info<'a> {
10619            name: &'a str,
10620            #[serde(rename = "piece length")]
10621            piece_length: u64,
10622            #[serde(with = "serde_bytes")]
10623            pieces: &'a [u8],
10624            files: Vec<FileE>,
10625        }
10626
10627        #[derive(Serialize)]
10628        struct Torrent<'a> {
10629            info: Info<'a>,
10630        }
10631
10632        let mut pieces = Vec::new();
10633        let mut offset = 0;
10634        while offset < data.len() {
10635            let end = (offset + piece_length as usize).min(data.len());
10636            let hash = irontide_core::sha1(&data[offset..end]);
10637            pieces.extend_from_slice(hash.as_bytes());
10638            offset = end;
10639        }
10640
10641        let files: Vec<FileE> = file_lengths
10642            .iter()
10643            .enumerate()
10644            .map(|(i, &len)| FileE {
10645                length: len,
10646                path: vec![format!("file{i}.bin")],
10647            })
10648            .collect();
10649
10650        let t = Torrent {
10651            info: Info {
10652                name: "test_multi",
10653                piece_length,
10654                pieces: &pieces,
10655                files,
10656            },
10657        };
10658
10659        let bytes = irontide_bencode::to_bytes(&t).unwrap();
10660        torrent_from_bytes(&bytes).unwrap()
10661    }
10662
10663    #[tokio::test]
10664    async fn file_progress_length_matches_file_count() {
10665        // 3 files: 10000 + 20000 + 2768 = 32768 bytes total, 2 pieces of 16384
10666        let data = vec![0xCD; 32768];
10667        let file_lengths = [10000u64, 20000, 2768];
10668        let meta = make_test_torrent_multi(&data, 16384, &file_lengths);
10669        let storage = make_storage(&data, 16384);
10670        let config = test_config();
10671
10672        let (atx, amask) = test_alert_channel();
10673        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10674        let handle = TorrentHandle::from_torrent(
10675            meta,
10676            irontide_core::TorrentVersion::V1Only,
10677            None,
10678            dh,
10679            dm,
10680            config,
10681            test_dht_rx(),
10682            test_dht_rx(),
10683            None,
10684            None,
10685            crate::slot_tuner::SlotTuner::disabled(4),
10686            atx,
10687            amask,
10688            None,
10689            None,
10690            test_ban_manager(),
10691            test_ip_filter(),
10692            Arc::new(Vec::new()),
10693            None,
10694            None,
10695            Arc::new(crate::transport::NetworkFactory::tokio()),
10696            None, // M96: hash_pool
10697            Arc::new(crate::stats::SessionCounters::new()),
10698        )
10699        .await
10700        .unwrap();
10701
10702        let progress = handle.file_progress().await.unwrap();
10703        assert_eq!(
10704            progress.len(),
10705            3,
10706            "multi-file torrent should have 3 entries"
10707        );
10708        assert!(
10709            progress.iter().all(|&b| b == 0),
10710            "all progress should be 0 initially"
10711        );
10712
10713        handle.shutdown().await.unwrap();
10714    }
10715
10716    // ---- Test: is_valid returns true for active torrent ----
10717
10718    #[tokio::test]
10719    async fn is_valid_true_for_active() {
10720        let data = vec![0xAB; 32768];
10721        let meta = make_test_torrent(&data, 16384);
10722        let storage = make_storage(&data, 16384);
10723        let config = test_config();
10724
10725        let (atx, amask) = test_alert_channel();
10726        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10727        let handle = TorrentHandle::from_torrent(
10728            meta,
10729            irontide_core::TorrentVersion::V1Only,
10730            None,
10731            dh,
10732            dm,
10733            config,
10734            test_dht_rx(),
10735            test_dht_rx(),
10736            None,
10737            None,
10738            crate::slot_tuner::SlotTuner::disabled(4),
10739            atx,
10740            amask,
10741            None,
10742            None,
10743            test_ban_manager(),
10744            test_ip_filter(),
10745            Arc::new(Vec::new()),
10746            None,
10747            None,
10748            Arc::new(crate::transport::NetworkFactory::tokio()),
10749            None, // M96: hash_pool
10750            Arc::new(crate::stats::SessionCounters::new()),
10751        )
10752        .await
10753        .unwrap();
10754
10755        assert!(
10756            handle.is_valid(),
10757            "handle should be valid while torrent actor is alive"
10758        );
10759
10760        handle.shutdown().await.unwrap();
10761    }
10762
10763    // ---- Test: is_valid returns false after shutdown ----
10764
10765    #[tokio::test]
10766    async fn is_valid_false_after_remove() {
10767        let data = vec![0xAB; 32768];
10768        let meta = make_test_torrent(&data, 16384);
10769        let storage = make_storage(&data, 16384);
10770        let config = test_config();
10771
10772        let (atx, amask) = test_alert_channel();
10773        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10774        let handle = TorrentHandle::from_torrent(
10775            meta,
10776            irontide_core::TorrentVersion::V1Only,
10777            None,
10778            dh,
10779            dm,
10780            config,
10781            test_dht_rx(),
10782            test_dht_rx(),
10783            None,
10784            None,
10785            crate::slot_tuner::SlotTuner::disabled(4),
10786            atx,
10787            amask,
10788            None,
10789            None,
10790            test_ban_manager(),
10791            test_ip_filter(),
10792            Arc::new(Vec::new()),
10793            None,
10794            None,
10795            Arc::new(crate::transport::NetworkFactory::tokio()),
10796            None, // M96: hash_pool
10797            Arc::new(crate::stats::SessionCounters::new()),
10798        )
10799        .await
10800        .unwrap();
10801
10802        assert!(handle.is_valid());
10803
10804        // Shutdown the torrent (simulating removal)
10805        handle.shutdown().await.unwrap();
10806
10807        // Give the actor time to stop and close the channel
10808        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10809
10810        assert!(
10811            !handle.is_valid(),
10812            "handle should be invalid after shutdown"
10813        );
10814    }
10815
10816    // ---- Test: clear_error resets error state ----
10817
10818    #[tokio::test]
10819    async fn clear_error_resets() {
10820        let data = vec![0xAB; 32768];
10821        let meta = make_test_torrent(&data, 16384);
10822        let storage = make_storage(&data, 16384);
10823        let config = test_config();
10824
10825        let (atx, amask) = test_alert_channel();
10826        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10827        let handle = TorrentHandle::from_torrent(
10828            meta,
10829            irontide_core::TorrentVersion::V1Only,
10830            None,
10831            dh,
10832            dm,
10833            config,
10834            test_dht_rx(),
10835            test_dht_rx(),
10836            None,
10837            None,
10838            crate::slot_tuner::SlotTuner::disabled(4),
10839            atx,
10840            amask,
10841            None,
10842            None,
10843            test_ban_manager(),
10844            test_ip_filter(),
10845            Arc::new(Vec::new()),
10846            None,
10847            None,
10848            Arc::new(crate::transport::NetworkFactory::tokio()),
10849            None, // M96: hash_pool
10850            Arc::new(crate::stats::SessionCounters::new()),
10851        )
10852        .await
10853        .unwrap();
10854
10855        // Initially no error
10856        let stats = handle.stats().await.unwrap();
10857        assert!(stats.error.is_empty());
10858        assert_eq!(stats.error_file, -1);
10859
10860        // Clear error (no-op when no error) should succeed without issue
10861        handle.clear_error().await.unwrap();
10862
10863        let stats = handle.stats().await.unwrap();
10864        assert!(stats.error.is_empty());
10865        assert_eq!(stats.error_file, -1);
10866
10867        handle.shutdown().await.unwrap();
10868    }
10869
10870    // ---- Test: flags round trip ----
10871
10872    #[tokio::test]
10873    async fn flags_round_trip() {
10874        let data = vec![0xAB; 32768];
10875        let meta = make_test_torrent(&data, 16384);
10876        let storage = make_storage(&data, 16384);
10877        let config = test_config();
10878
10879        let (atx, amask) = test_alert_channel();
10880        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10881        let handle = TorrentHandle::from_torrent(
10882            meta,
10883            irontide_core::TorrentVersion::V1Only,
10884            None,
10885            dh,
10886            dm,
10887            config,
10888            test_dht_rx(),
10889            test_dht_rx(),
10890            None,
10891            None,
10892            crate::slot_tuner::SlotTuner::disabled(4),
10893            atx,
10894            amask,
10895            None,
10896            None,
10897            test_ban_manager(),
10898            test_ip_filter(),
10899            Arc::new(Vec::new()),
10900            None,
10901            None,
10902            Arc::new(crate::transport::NetworkFactory::tokio()),
10903            None, // M96: hash_pool
10904            Arc::new(crate::stats::SessionCounters::new()),
10905        )
10906        .await
10907        .unwrap();
10908
10909        // Initial flags: torrent starts downloading (not paused), no sequential, no super seeding
10910        let initial = handle.flags().await.unwrap();
10911        assert!(!initial.contains(crate::types::TorrentFlags::PAUSED));
10912        assert!(!initial.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10913        assert!(!initial.contains(crate::types::TorrentFlags::SUPER_SEEDING));
10914
10915        // Enable sequential download via set_flags
10916        handle
10917            .set_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10918            .await
10919            .unwrap();
10920        let after_set = handle.flags().await.unwrap();
10921        assert!(after_set.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10922
10923        // Disable it via unset_flags
10924        handle
10925            .unset_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10926            .await
10927            .unwrap();
10928        let after_unset = handle.flags().await.unwrap();
10929        assert!(!after_unset.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10930
10931        // Verify sequential_download state via the dedicated query
10932        assert!(!handle.is_sequential_download().await.unwrap());
10933
10934        handle.shutdown().await.unwrap();
10935    }
10936
10937    // ---- Test: connect_peer does not error ----
10938
10939    #[tokio::test]
10940    async fn connect_peer_no_error() {
10941        let data = vec![0xAB; 32768];
10942        let meta = make_test_torrent(&data, 16384);
10943        let storage = make_storage(&data, 16384);
10944        let config = test_config();
10945
10946        let (atx, amask) = test_alert_channel();
10947        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10948        let handle = TorrentHandle::from_torrent(
10949            meta,
10950            irontide_core::TorrentVersion::V1Only,
10951            None,
10952            dh,
10953            dm,
10954            config,
10955            test_dht_rx(),
10956            test_dht_rx(),
10957            None,
10958            None,
10959            crate::slot_tuner::SlotTuner::disabled(4),
10960            atx,
10961            amask,
10962            None,
10963            None,
10964            test_ban_manager(),
10965            test_ip_filter(),
10966            Arc::new(Vec::new()),
10967            None,
10968            None,
10969            Arc::new(crate::transport::NetworkFactory::tokio()),
10970            None, // M96: hash_pool
10971            Arc::new(crate::stats::SessionCounters::new()),
10972        )
10973        .await
10974        .unwrap();
10975
10976        // connect_peer should not error even though the peer doesn't exist
10977        // (the connection attempt will fail asynchronously, but the command itself succeeds)
10978        let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap();
10979        handle.connect_peer(addr).await.unwrap();
10980
10981        // Give the actor a moment to process
10982        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
10983
10984        handle.shutdown().await.unwrap();
10985    }
10986
10987    // ---- BEP 52 hash serving tests (M87) ----
10988
10989    /// Build a minimal `TorrentMetaV2` with piece-layer hashes for testing.
10990    fn make_test_meta_v2(
10991        piece_hashes: &[irontide_core::Id32],
10992        file_root: irontide_core::Id32,
10993        piece_length: u64,
10994        file_length: u64,
10995    ) -> irontide_core::TorrentMetaV2 {
10996        use std::collections::BTreeMap;
10997
10998        // Concatenate piece hashes into raw bytes
10999        let mut layer_bytes = Vec::with_capacity(piece_hashes.len() * 32);
11000        for h in piece_hashes {
11001            layer_bytes.extend_from_slice(&h.0);
11002        }
11003
11004        let mut piece_layers = BTreeMap::new();
11005        piece_layers.insert(file_root, layer_bytes);
11006
11007        let file_tree = irontide_core::FileTreeNode::Directory({
11008            let mut children = BTreeMap::new();
11009            children.insert(
11010                "test.dat".to_string(),
11011                irontide_core::FileTreeNode::File(irontide_core::V2FileAttr {
11012                    length: file_length,
11013                    pieces_root: Some(file_root),
11014                }),
11015            );
11016            children
11017        });
11018
11019        irontide_core::TorrentMetaV2 {
11020            info_hashes: irontide_core::InfoHashes::v2_only(irontide_core::Id32::ZERO),
11021            info_bytes: None,
11022            announce: None,
11023            announce_list: None,
11024            comment: None,
11025            created_by: None,
11026            creation_date: None,
11027            info: irontide_core::InfoDictV2 {
11028                name: "test".to_string(),
11029                piece_length,
11030                meta_version: 2,
11031                file_tree,
11032                ssl_cert: None,
11033            },
11034            piece_layers,
11035            ssl_cert: None,
11036        }
11037    }
11038
11039    #[test]
11040    fn test_serve_hashes_v2_piece_layer() {
11041        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11042        // => blocks_per_piece = 1, piece_layer_base = 0
11043        let hashes: Vec<irontide_core::Id32> = (0..4u8)
11044            .map(|i| {
11045                let mut h = [0u8; 32];
11046                h[0] = i;
11047                irontide_core::Id32(h)
11048            })
11049            .collect();
11050        let file_root = irontide_core::Id32([0xAA; 32]);
11051        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11052        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11053
11054        let request = irontide_core::HashRequest {
11055            file_root,
11056            base: 0, // piece layer when blocks_per_piece = 1
11057            index: 0,
11058            count: 4,
11059            proof_layers: 0,
11060        };
11061
11062        let result = serve_hashes(
11063            Some(&meta),
11064            irontide_core::TorrentVersion::V2Only,
11065            Some(&lengths),
11066            &request,
11067        );
11068        let served = result.expect("should serve hashes");
11069        assert_eq!(served.len(), 4);
11070        for (i, h) in served.iter().enumerate() {
11071            assert_eq!(h.0[0], i as u8);
11072        }
11073    }
11074
11075    #[test]
11076    fn test_serve_hashes_rejects_v1_only() {
11077        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11078        let file_root = irontide_core::Id32([0xAA; 32]);
11079        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11080        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11081
11082        let request = irontide_core::HashRequest {
11083            file_root,
11084            base: 0,
11085            index: 0,
11086            count: 1,
11087            proof_layers: 0,
11088        };
11089
11090        let result = serve_hashes(
11091            Some(&meta),
11092            irontide_core::TorrentVersion::V1Only,
11093            Some(&lengths),
11094            &request,
11095        );
11096        assert!(result.is_none(), "V1Only should reject hash requests");
11097    }
11098
11099    #[test]
11100    fn test_serve_hashes_rejects_unknown_root() {
11101        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11102        let file_root = irontide_core::Id32([0xAA; 32]);
11103        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11104        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11105
11106        // Request a different file root that doesn't exist
11107        let unknown_root = irontide_core::Id32([0xFF; 32]);
11108        let request = irontide_core::HashRequest {
11109            file_root: unknown_root,
11110            base: 0,
11111            index: 0,
11112            count: 1,
11113            proof_layers: 0,
11114        };
11115
11116        let result = serve_hashes(
11117            Some(&meta),
11118            irontide_core::TorrentVersion::V2Only,
11119            Some(&lengths),
11120            &request,
11121        );
11122        assert!(result.is_none(), "unknown file_root should reject");
11123    }
11124
11125    #[test]
11126    fn test_serve_hashes_rejects_out_of_bounds() {
11127        // 2 piece hashes, piece_length = 16384, chunk_size = 16384
11128        let hashes: Vec<irontide_core::Id32> =
11129            (0..2u8).map(|i| irontide_core::Id32([i; 32])).collect();
11130        let file_root = irontide_core::Id32([0xAA; 32]);
11131        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 2);
11132        let lengths = Lengths::new(16384 * 2, 16384, DEFAULT_CHUNK_SIZE);
11133
11134        // Request starting at index 5, which is beyond the 2 available hashes
11135        let request = irontide_core::HashRequest {
11136            file_root,
11137            base: 0,
11138            index: 5,
11139            count: 1,
11140            proof_layers: 0,
11141        };
11142
11143        let result = serve_hashes(
11144            Some(&meta),
11145            irontide_core::TorrentVersion::V2Only,
11146            Some(&lengths),
11147            &request,
11148        );
11149        assert!(result.is_none(), "out-of-bounds index should reject");
11150    }
11151
11152    #[test]
11153    fn test_serve_hashes_includes_proofs() {
11154        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11155        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11156        let hashes: Vec<irontide_core::Id32> =
11157            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11158        let file_root = irontide_core::Id32([0xAA; 32]);
11159        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11160        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11161
11162        // Request 1 hash with 1 proof layer
11163        let request = irontide_core::HashRequest {
11164            file_root,
11165            base: 0,
11166            index: 0,
11167            count: 1,
11168            proof_layers: 1,
11169        };
11170
11171        let result = serve_hashes(
11172            Some(&meta),
11173            irontide_core::TorrentVersion::V2Only,
11174            Some(&lengths),
11175            &request,
11176        );
11177        let served = result.expect("should serve hashes with proofs");
11178        // 1 requested hash + 1 proof hash (sibling of leaf 0) = 2 total
11179        assert_eq!(served.len(), 2, "should have 1 data hash + 1 proof hash");
11180        // First hash is the requested piece hash
11181        assert_eq!(served[0], hashes[0]);
11182        // Second hash is the sibling (proof) — which is hashes[1]
11183        assert_eq!(served[1], hashes[1]);
11184    }
11185
11186    #[test]
11187    fn test_serve_hashes_proof_with_batch() {
11188        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11189        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11190        //
11191        // Tree layout (1-indexed heap):
11192        //          [1] root
11193        //        /          \
11194        //     [2]            [3]
11195        //    /    \         /    \
11196        //  [4]h0  [5]h1  [6]h2  [7]h3
11197        //
11198        // Request count=2 at index=0 => subtree rooted at [2] (h0, h1).
11199        // subtree_depth = log2(2) = 1, so we skip 1 level of the proof path.
11200        // proof_path(0) = [h1, hash(h2,h3)] — h1 is internal to subtree,
11201        // hash(h2,h3) is the uncle above. We skip h1 and send hash(h2,h3).
11202        let hashes: Vec<irontide_core::Id32> =
11203            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11204        let file_root = irontide_core::Id32([0xAA; 32]);
11205        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11206        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11207
11208        let request = irontide_core::HashRequest {
11209            file_root,
11210            base: 0,
11211            index: 0,
11212            count: 2,
11213            proof_layers: 1,
11214        };
11215
11216        let result = serve_hashes(
11217            Some(&meta),
11218            irontide_core::TorrentVersion::V2Only,
11219            Some(&lengths),
11220            &request,
11221        );
11222        let served = result.expect("should serve hashes with batch proof");
11223        // 2 base hashes + 1 uncle hash = 3 total
11224        assert_eq!(served.len(), 3, "should have 2 data hashes + 1 uncle hash");
11225        // First two are the requested piece hashes
11226        assert_eq!(served[0], hashes[0]);
11227        assert_eq!(served[1], hashes[1]);
11228        // Third is the uncle: sibling of the subtree root at [2],
11229        // which is the node at [3] = hash(h2, h3)
11230        let tree = irontide_core::MerkleTree::from_leaves(&hashes);
11231        let expected_uncle = tree.layer(1)[1]; // layer 1 has 2 nodes; index 1 is the right one
11232        assert_eq!(served[2], expected_uncle);
11233
11234        // Verify the proof is valid: reconstruct subtree root from base hashes,
11235        // then verify against the tree root using the uncle hash
11236        let sub_root = irontide_core::MerkleTree::root_from_hashes(&served[..2]);
11237        let uncle_hashes = &served[2..];
11238        let leaf_index = request.index as usize / 2; // 0 / 2 = 0
11239        assert!(
11240            irontide_core::MerkleTree::verify_proof(
11241                tree.root(),
11242                sub_root,
11243                leaf_index,
11244                uncle_hashes
11245            ),
11246            "subtree proof should verify against tree root"
11247        );
11248    }
11249
11250    #[test]
11251    fn is_i2p_synthetic_addr_detects_240_range() {
11252        assert!(is_i2p_synthetic_addr(&"240.0.0.1:1".parse().unwrap()));
11253        assert!(is_i2p_synthetic_addr(
11254            &"255.255.255.255:65535".parse().unwrap()
11255        ));
11256        assert!(!is_i2p_synthetic_addr(&"192.168.1.1:6881".parse().unwrap()));
11257        assert!(!is_i2p_synthetic_addr(&"[::1]:6881".parse().unwrap()));
11258    }
11259
11260    #[test]
11261    fn v6_retry_delay_progression() {
11262        // Verify exponential backoff: 100, 200, 400, 800, 1600, 3200, 5000, 5000...
11263        let expected_ms = [100, 200, 400, 800, 1600, 3200, 5000, 5000, 5000, 5000, 5000];
11264        for (count, &expected) in expected_ms.iter().enumerate() {
11265            let delay_ms = {
11266                let base_ms: u64 = 100;
11267                let max_ms: u64 = 5000;
11268                base_ms
11269                    .saturating_mul(1u64.checked_shl(count as u32).unwrap_or(u64::MAX))
11270                    .min(max_ms)
11271            };
11272            assert_eq!(
11273                delay_ms, expected,
11274                "count={count}: expected {expected}ms, got {delay_ms}ms"
11275            );
11276        }
11277    }
11278
11279    // ---- M104: Per-peer backoff and max_in_flight formula tests ----
11280
11281    #[test]
11282    fn peer_backoff_exponential() {
11283        // Verify the M104 backoff formula: 200ms * 2^attempt, capped at 30s.
11284        // attempt starts at 1 (first failure increments 0 → 1).
11285        let expected_ms: Vec<u64> = vec![400, 800, 1600, 3200, 6400, 12800, 25600, 30000, 30000];
11286        for (i, &expected) in expected_ms.iter().enumerate() {
11287            let attempt = (i as u32) + 1; // attempt counts start at 1
11288            let delay_ms = 200u64.saturating_mul(1u64 << attempt.min(10)).min(30_000);
11289            assert_eq!(
11290                delay_ms, expected,
11291                "attempt={attempt}: expected {expected}ms, got {delay_ms}ms"
11292            );
11293        }
11294    }
11295
11296    #[test]
11297    fn peer_backoff_clears_on_data() {
11298        // Verify that backoff map operations work correctly:
11299        // insert on disconnect, remove on data received.
11300        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11301        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11302
11303        // No backoff initially
11304        assert!(!backoff.contains_key(&addr));
11305
11306        // First disconnect: attempt 1
11307        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11308        let next = attempt.saturating_add(1);
11309        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11310        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11311        backoff.insert(addr, (earliest, next));
11312        assert_eq!(backoff.get(&addr).unwrap().1, 1);
11313
11314        // Second disconnect: attempt 2
11315        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11316        let next = attempt.saturating_add(1);
11317        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11318        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11319        backoff.insert(addr, (earliest, next));
11320        assert_eq!(backoff.get(&addr).unwrap().1, 2);
11321
11322        // Data received: clear
11323        backoff.remove(&addr);
11324        assert!(!backoff.contains_key(&addr));
11325    }
11326
11327    #[test]
11328    fn backoff_prevents_hammering() {
11329        // Verify that a peer with a future backoff time would be skipped.
11330        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11331        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11332
11333        // Set backoff 10 seconds in the future
11334        let future = std::time::Instant::now() + Duration::from_secs(10);
11335        backoff.insert(addr, (future, 3));
11336
11337        // Should be skipped (now < next_attempt)
11338        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11339            assert!(std::time::Instant::now() < next_attempt);
11340        }
11341
11342        // Set backoff in the past — should NOT be skipped
11343        let past = std::time::Instant::now() - Duration::from_secs(1);
11344        backoff.insert(addr, (past, 3));
11345        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11346            assert!(std::time::Instant::now() >= next_attempt);
11347        }
11348    }
11349
11350    #[test]
11351    fn max_in_flight_formula_updated() {
11352        // M104: max(512, connected*4) clamped to pieces/2, floored at 512.
11353        let formula = |connected: usize, num_pieces: u32| -> usize {
11354            let calculated = 512usize.max(connected.saturating_mul(4));
11355            calculated.min(num_pieces as usize / 2).max(512)
11356        };
11357
11358        // Few peers: floor dominates
11359        assert_eq!(formula(10, 2000), 512);
11360
11361        // Many peers: connected * 4 takes over
11362        assert_eq!(formula(200, 2000), 800);
11363
11364        // Very many peers: clamped by pieces/2
11365        assert_eq!(formula(500, 2000), 1000); // 2000 clamped to 1000
11366
11367        // Tiny torrent: floor dominates even with many peers
11368        assert_eq!(formula(200, 100), 512); // 800 clamped to 50, floored to 512
11369
11370        // Exact boundary
11371        assert_eq!(formula(128, 10000), 512); // 128*4=512, max(512,512)=512
11372        assert_eq!(formula(129, 10000), 516); // 129*4=516, max(512,516)=516
11373
11374        // Zero peers
11375        assert_eq!(formula(0, 10000), 512);
11376
11377        // Zero pieces (edge case — would give pieces/2=0, floor=512)
11378        assert_eq!(formula(100, 0), 512);
11379    }
11380
11381    // -- BEP 55 holepunch initiation tests (M112) --
11382
11383    #[test]
11384    fn should_attempt_holepunch_reason_classification() {
11385        // NAT-related reasons → true
11386        assert!(should_attempt_holepunch("connection refused"));
11387        assert!(should_attempt_holepunch("Connection refused"));
11388        assert!(should_attempt_holepunch("timed out"));
11389        assert!(should_attempt_holepunch("Connection reset by peer"));
11390        assert!(should_attempt_holepunch("connection reset by peer"));
11391        // Re-entrancy guard: holepunch-originated failures → false
11392        assert!(!should_attempt_holepunch(
11393            "holepunch TCP connect failed: Connection refused"
11394        ));
11395        // Non-NAT reasons → false
11396        assert!(!should_attempt_holepunch("peer banned"));
11397        assert!(!should_attempt_holepunch("protocol error"));
11398        assert!(!should_attempt_holepunch(""));
11399    }
11400
11401    #[test]
11402    fn holepunch_initiation_on_connect_failure() {
11403        // "connection refused" is the canonical NAT failure reason
11404        assert!(should_attempt_holepunch("connection refused"));
11405    }
11406
11407    #[test]
11408    fn holepunch_cooldown_prevents_retry() {
11409        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11410        let addr: SocketAddr = "127.0.0.1:6881".parse().expect("valid test addr");
11411        let now = Instant::now();
11412        cooldowns.insert(addr, now);
11413        // addr is in cooldowns, so should be skipped on subsequent attempt
11414        assert!(cooldowns.contains_key(&addr));
11415    }
11416
11417    #[test]
11418    fn holepunch_cooldown_overflow_skips() {
11419        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11420        let now = Instant::now();
11421        for i in 0..256u16 {
11422            let addr: SocketAddr = format!("10.0.{}.{}:6881", i / 256, i % 256)
11423                .parse()
11424                .expect("valid test addr");
11425            cooldowns.insert(addr, now);
11426        }
11427        assert_eq!(cooldowns.len(), HOLEPUNCH_MAX_TRACKED);
11428        // New entry should be skipped when at capacity
11429    }
11430
11431    #[test]
11432    fn holepunch_skipped_when_disabled() {
11433        // should_attempt_holepunch only checks the reason string, not config.
11434        // Config check happens in disconnect_peer.
11435        assert!(should_attempt_holepunch("connection refused"));
11436        // This test documents that should_attempt_holepunch is reason-only.
11437    }
11438
11439    #[test]
11440    fn holepunch_not_triggered_on_ban() {
11441        assert!(!should_attempt_holepunch("peer banned"));
11442        assert!(!should_attempt_holepunch("banned for bad data"));
11443    }
11444
11445    // -- M116: CachedFileInfo tests --
11446
11447    /// Helper to build a minimal `TorrentMetaV1` with multi-file entries.
11448    fn make_multi_file_meta(files: &[(u64, &str)], piece_length: u64) -> TorrentMetaV1 {
11449        let total_length: u64 = files.iter().map(|(len, _)| *len).sum();
11450        let num_pieces = total_length.div_ceil(piece_length) as usize;
11451        let file_entries: Vec<irontide_core::FileEntry> = files
11452            .iter()
11453            .map(|(length, name)| irontide_core::FileEntry {
11454                length: *length,
11455                path: vec![name.to_string()],
11456                attr: None,
11457                mtime: None,
11458                symlink_path: None,
11459            })
11460            .collect();
11461        TorrentMetaV1 {
11462            info_hash: Id20([0u8; 20]),
11463            announce: None,
11464            announce_list: None,
11465            comment: None,
11466            created_by: None,
11467            creation_date: None,
11468            info: irontide_core::InfoDict {
11469                name: "test".to_string(),
11470                piece_length,
11471                pieces: vec![0u8; num_pieces * 20],
11472                length: None,
11473                files: Some(file_entries),
11474                private: None,
11475                source: None,
11476                ssl_cert: None,
11477                similar: Vec::new(),
11478                collections: Vec::new(),
11479            },
11480            url_list: Vec::new(),
11481            httpseeds: Vec::new(),
11482            info_bytes: None,
11483            ssl_cert: None,
11484        }
11485    }
11486
11487    #[test]
11488    fn cached_files_populated_on_registration() {
11489        // 3 files: 100, 200, 50 bytes; piece_length = 100
11490        // Total = 350 bytes, 4 pieces (0..3)
11491        // File 0: offset 0..100  -> pieces [0, 0]
11492        // File 1: offset 100..300 -> pieces [1, 2]
11493        // File 2: offset 300..350 -> pieces [3, 3]
11494        let meta = make_multi_file_meta(&[(100, "a.txt"), (200, "b.txt"), (50, "c.txt")], 100);
11495        let lengths = Lengths::new(350, 100, 16384);
11496        let cached = build_cached_file_info(&meta, &lengths);
11497
11498        assert_eq!(cached.entries.len(), 3);
11499
11500        assert_eq!(cached.entries[0].index, 0);
11501        assert_eq!(cached.entries[0].length, 100);
11502        assert_eq!(cached.entries[0].first_piece, 0);
11503        assert_eq!(cached.entries[0].last_piece, 0);
11504
11505        assert_eq!(cached.entries[1].index, 1);
11506        assert_eq!(cached.entries[1].length, 200);
11507        assert_eq!(cached.entries[1].first_piece, 1);
11508        assert_eq!(cached.entries[1].last_piece, 2);
11509
11510        assert_eq!(cached.entries[2].index, 2);
11511        assert_eq!(cached.entries[2].length, 50);
11512        assert_eq!(cached.entries[2].first_piece, 3);
11513        assert_eq!(cached.entries[2].last_piece, 3);
11514    }
11515
11516    #[test]
11517    fn cached_files_single_file_torrent() {
11518        // Single-file torrent: 500 bytes, piece_length = 100
11519        // 5 pieces (0..4)
11520        let meta = TorrentMetaV1 {
11521            info_hash: Id20([0u8; 20]),
11522            announce: None,
11523            announce_list: None,
11524            comment: None,
11525            created_by: None,
11526            creation_date: None,
11527            info: irontide_core::InfoDict {
11528                name: "single.bin".to_string(),
11529                piece_length: 100,
11530                pieces: vec![0u8; 5 * 20],
11531                length: Some(500),
11532                files: None,
11533                private: None,
11534                source: None,
11535                ssl_cert: None,
11536                similar: Vec::new(),
11537                collections: Vec::new(),
11538            },
11539            url_list: Vec::new(),
11540            httpseeds: Vec::new(),
11541            info_bytes: None,
11542            ssl_cert: None,
11543        };
11544        let lengths = Lengths::new(500, 100, 16384);
11545        let cached = build_cached_file_info(&meta, &lengths);
11546
11547        assert_eq!(cached.entries.len(), 1);
11548        assert_eq!(cached.entries[0].index, 0);
11549        assert_eq!(cached.entries[0].length, 500);
11550        assert_eq!(cached.entries[0].first_piece, 0);
11551        assert_eq!(cached.entries[0].last_piece, 4);
11552    }
11553
11554    // ── M132: Time-based steal-queue population tests ──
11555    //
11556    // These tests verify the steal-populate logic that runs in run_steal_queue_maintenance().
11557    // They build AtomicPieceStates and StealCandidates directly and exercise the
11558    // same scan loop used by the real implementation.
11559
11560    use crate::piece_reservation::{AtomicPieceStates, PieceState, StealCandidates};
11561    use irontide_storage::Bitfield;
11562
11563    /// Helper: run the steal-populate scan (mirrors `run_steal_queue_maintenance`).
11564    ///
11565    /// Returns the number of pieces pushed into the steal queue.
11566    fn steal_populate_scan(states: &AtomicPieceStates, sc: &StealCandidates) -> u32 {
11567        let mut pushed = 0u32;
11568        let num = states.len();
11569        for piece in 0..num {
11570            let state = states.get(piece);
11571            if state == PieceState::Reserved {
11572                sc.push(piece);
11573                pushed = pushed.saturating_add(1);
11574            }
11575        }
11576        pushed
11577    }
11578
11579    fn all_wanted(n: u32) -> Bitfield {
11580        let mut bf = Bitfield::new(n);
11581        for i in 0..n {
11582            bf.set(i);
11583        }
11584        bf
11585    }
11586
11587    #[test]
11588    fn steal_populate_pushes_reserved_pieces() {
11589        let n = 10;
11590        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11591        let sc = StealCandidates::new();
11592
11593        // Reserve pieces 2, 5, 7
11594        assert!(states.try_reserve(2));
11595        assert!(states.try_reserve(5));
11596        assert!(states.try_reserve(7));
11597
11598        let pushed = steal_populate_scan(&states, &sc);
11599        assert_eq!(pushed, 3, "should push exactly the 3 reserved pieces");
11600
11601        // Verify they're in the queue
11602        let mut popped = Vec::new();
11603        while let Some(p) = sc.pop() {
11604            popped.push(p);
11605        }
11606        popped.sort_unstable();
11607        assert_eq!(popped, vec![2, 5, 7]);
11608    }
11609
11610    #[test]
11611    fn steal_populate_skips_non_reserved_states() {
11612        let n = 8;
11613        let mut have = Bitfield::new(n);
11614        have.set(0); // piece 0 = Complete
11615        let mut wanted = all_wanted(n);
11616        wanted.clear(1); // piece 1 = Unwanted
11617
11618        let states = AtomicPieceStates::new(n, &have, &wanted);
11619        let sc = StealCandidates::new();
11620
11621        // Reserve piece 3, leave rest as Available/Complete/Unwanted
11622        assert!(states.try_reserve(3));
11623
11624        let pushed = steal_populate_scan(&states, &sc);
11625        assert_eq!(pushed, 1, "only piece 3 (Reserved) should be pushed");
11626
11627        assert_eq!(sc.pop(), Some(3));
11628        assert_eq!(sc.pop(), None);
11629    }
11630
11631    #[test]
11632    fn steal_populate_deduplicates() {
11633        let n = 4;
11634        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11635        let sc = StealCandidates::new();
11636
11637        assert!(states.try_reserve(1));
11638        assert!(states.try_reserve(2));
11639
11640        // First scan pushes 2 pieces
11641        let pushed1 = steal_populate_scan(&states, &sc);
11642        assert_eq!(pushed1, 2);
11643
11644        // Second scan: StealCandidates.push() deduplicates, so the queue
11645        // should still contain exactly 2 entries, not 4.
11646        let pushed2 = steal_populate_scan(&states, &sc);
11647        assert_eq!(pushed2, 2, "scan still reports 2 reserved pieces");
11648
11649        let mut count = 0u32;
11650        while sc.pop().is_some() {
11651            count = count.saturating_add(1);
11652        }
11653        assert_eq!(count, 2, "dedup means only 2 entries despite 2 scans");
11654    }
11655
11656    #[test]
11657    fn steal_populate_skips_completed_pieces() {
11658        let n = 5;
11659        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11660        let sc = StealCandidates::new();
11661
11662        // Reserve all 5 pieces
11663        for i in 0..n {
11664            assert!(states.try_reserve(i));
11665        }
11666
11667        // Complete pieces 1 and 3 before the scan
11668        states.mark_complete(1);
11669        states.mark_complete(3);
11670
11671        let pushed = steal_populate_scan(&states, &sc);
11672        assert_eq!(pushed, 3, "3 pieces still Reserved (0, 2, 4)");
11673
11674        let mut popped = Vec::new();
11675        while let Some(p) = sc.pop() {
11676            popped.push(p);
11677        }
11678        popped.sort_unstable();
11679        assert_eq!(popped, vec![0, 2, 4]);
11680    }
11681
11682    #[test]
11683    fn steal_populate_empty_when_no_reserved() {
11684        let n = 6;
11685        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11686        let sc = StealCandidates::new();
11687
11688        // No pieces reserved — scan should push nothing
11689        let pushed = steal_populate_scan(&states, &sc);
11690        assert_eq!(pushed, 0);
11691        assert_eq!(sc.pop(), None);
11692    }
11693
11694    #[test]
11695    fn steal_populate_with_endgame_pieces() {
11696        // Endgame pieces (state = Endgame) should NOT be pushed — only Reserved.
11697        let n = 4;
11698        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11699        let sc = StealCandidates::new();
11700
11701        assert!(states.try_reserve(0));
11702        assert!(states.try_reserve(1));
11703        states.transition_to_endgame(1);
11704
11705        let pushed = steal_populate_scan(&states, &sc);
11706        assert_eq!(
11707            pushed, 1,
11708            "only piece 0 (Reserved) should be pushed, not piece 1 (Endgame)"
11709        );
11710        assert_eq!(sc.pop(), Some(0));
11711        assert_eq!(sc.pop(), None);
11712    }
11713
11714    // -------------------------------------------------------------------
11715    // F8: Piece state sync on file priority change
11716    // -------------------------------------------------------------------
11717
11718    #[test]
11719    fn sync_piece_states_marks_unwanted_on_skip() {
11720        let n = 8;
11721        let mut wanted = all_wanted(n);
11722        wanted.clear(2);
11723        wanted.clear(3);
11724        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11725        // Simulate: wanted_pieces was rebuilt but atomic_states not yet synced.
11726        // Pieces 2 and 3 are Available but no longer wanted.
11727        assert_eq!(states.get(2), PieceState::Available);
11728        assert_eq!(states.get(3), PieceState::Available);
11729
11730        // Run the sync logic directly.
11731        for piece in 0..n {
11732            let w = wanted.get(piece);
11733            let current = states.get(piece);
11734            if !w && current == PieceState::Available {
11735                states.mark_unwanted(piece);
11736            } else if w && current == PieceState::Unwanted {
11737                states.mark_available(piece);
11738            }
11739        }
11740
11741        assert_eq!(states.get(0), PieceState::Available);
11742        assert_eq!(states.get(2), PieceState::Unwanted);
11743        assert_eq!(states.get(3), PieceState::Unwanted);
11744        assert_eq!(states.get(4), PieceState::Available);
11745    }
11746
11747    #[test]
11748    fn sync_piece_states_restores_available_on_unskip() {
11749        let n = 6;
11750        let mut initial_wanted = all_wanted(n);
11751        initial_wanted.clear(1);
11752        initial_wanted.clear(4);
11753        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &initial_wanted);
11754        assert_eq!(states.get(1), PieceState::Unwanted);
11755        assert_eq!(states.get(4), PieceState::Unwanted);
11756
11757        // Now re-enable all pieces (simulate setting back to Normal).
11758        let new_wanted = all_wanted(n);
11759        for piece in 0..n {
11760            let w = new_wanted.get(piece);
11761            let current = states.get(piece);
11762            if !w && current == PieceState::Available {
11763                states.mark_unwanted(piece);
11764            } else if w && current == PieceState::Unwanted {
11765                states.mark_available(piece);
11766            }
11767        }
11768
11769        assert_eq!(states.get(1), PieceState::Available);
11770        assert_eq!(states.get(4), PieceState::Available);
11771    }
11772
11773    #[test]
11774    fn sync_piece_states_shared_piece_stays_available() {
11775        // A piece spanning a skipped and non-skipped file stays wanted
11776        // (build_wanted_pieces marks it wanted if any spanning file is
11777        // non-skip). Verify the sync leaves it Available.
11778        let n = 4;
11779        let mut wanted = all_wanted(n);
11780        wanted.clear(0); // exclusive to skipped file
11781        // Piece 1 is shared — stays wanted
11782        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11783
11784        for piece in 0..n {
11785            let w = wanted.get(piece);
11786            let current = states.get(piece);
11787            if !w && current == PieceState::Available {
11788                states.mark_unwanted(piece);
11789            } else if w && current == PieceState::Unwanted {
11790                states.mark_available(piece);
11791            }
11792        }
11793
11794        assert_eq!(states.get(0), PieceState::Unwanted);
11795        assert_eq!(
11796            states.get(1),
11797            PieceState::Available,
11798            "shared piece stays Available"
11799        );
11800        assert_eq!(states.get(2), PieceState::Available);
11801        assert_eq!(states.get(3), PieceState::Available);
11802    }
11803
11804    // -------------------------------------------------------------------
11805    // M133: DHT re-query tests
11806    // -------------------------------------------------------------------
11807
11808    /// Verify the DHT re-query guard scales with `max_peers` config.
11809    ///
11810    /// The guard threshold is `max_peers * 4`. With default `max_peers = 128`,
11811    /// this becomes 512 (close to the old hardcoded 500).
11812    #[test]
11813    fn dht_requery_guard_scales_with_max_peers() {
11814        // max_peers = 128 → threshold = 512
11815        assert_eq!(128_usize.saturating_mul(4), 512);
11816
11817        // max_peers = 200 → threshold = 800
11818        assert_eq!(200_usize.saturating_mul(4), 800);
11819
11820        // max_peers = 50 → threshold = 200
11821        assert_eq!(50_usize.saturating_mul(4), 200);
11822
11823        // Overflow protection: saturating_mul handles usize::MAX
11824        assert_eq!(usize::MAX.saturating_mul(4), usize::MAX);
11825    }
11826
11827    // ---- M147: Pre-resolved metadata tests ----
11828
11829    /// Build a raw bencoded info dict and its SHA1 hash (for magnet link testing).
11830    fn make_test_info_bytes() -> (Vec<u8>, Id20) {
11831        use serde::Serialize;
11832
11833        #[derive(Serialize)]
11834        struct Info<'a> {
11835            length: u64,
11836            name: &'a str,
11837            #[serde(rename = "piece length")]
11838            piece_length: u64,
11839            #[serde(with = "serde_bytes")]
11840            pieces: &'a [u8],
11841        }
11842
11843        let data = vec![0xAB; 1024];
11844        let piece_hash = irontide_core::sha1(&data);
11845        let mut pieces = Vec::new();
11846        pieces.extend_from_slice(piece_hash.as_bytes());
11847
11848        let info = Info {
11849            length: 1024,
11850            name: "test",
11851            piece_length: 16384,
11852            pieces: &pieces,
11853        };
11854
11855        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
11856        let info_hash = irontide_core::sha1(&info_bytes);
11857        (info_bytes, info_hash)
11858    }
11859
11860    /// Create a magnet-based `TorrentHandle` for testing `PreResolvedMetadata`.
11861    async fn create_magnet_handle(info_hash: Id20) -> TorrentHandle {
11862        let magnet = Magnet {
11863            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
11864            display_name: Some("test".into()),
11865            trackers: vec![],
11866            peers: vec![],
11867            selected_files: None,
11868        };
11869        let config = test_config();
11870        let (atx, amask) = test_alert_channel();
11871        let (dm, _dj) = test_disk_manager();
11872        TorrentHandle::from_magnet(
11873            magnet,
11874            dm,
11875            config,
11876            test_dht_rx(),
11877            test_dht_rx(),
11878            None,
11879            None,
11880            crate::slot_tuner::SlotTuner::disabled(4),
11881            atx,
11882            amask,
11883            None,
11884            None,
11885            test_ban_manager(),
11886            test_ip_filter(),
11887            Arc::new(Vec::new()),
11888            None,
11889            None,
11890            Arc::new(crate::transport::NetworkFactory::tokio()),
11891            None,
11892            Arc::new(crate::stats::SessionCounters::new()),
11893        )
11894        .await
11895        .unwrap()
11896    }
11897
11898    #[tokio::test]
11899    async fn pre_resolved_metadata_applies_when_fetching() {
11900        let (info_bytes, info_hash) = make_test_info_bytes();
11901        let handle = create_magnet_handle(info_hash).await;
11902
11903        // Verify we start in FetchingMetadata state.
11904        let stats = handle.stats().await.unwrap();
11905        assert_eq!(stats.state, TorrentState::FetchingMetadata);
11906
11907        // Send pre-resolved metadata (with a fake peer for pre-seeding).
11908        let peer_addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
11909        handle.send_pre_resolved_metadata(info_bytes, vec![peer_addr]);
11910
11911        // Give the actor time to process the command.
11912        tokio::time::sleep(Duration::from_millis(200)).await;
11913
11914        // Verify transition to Downloading state.
11915        let stats = handle.stats().await.unwrap();
11916        assert_eq!(
11917            stats.state,
11918            TorrentState::Downloading,
11919            "should have transitioned to Downloading after pre-resolved metadata"
11920        );
11921        assert!(
11922            stats.pieces_total > 0,
11923            "should know piece count after metadata resolution"
11924        );
11925
11926        handle.shutdown().await.unwrap();
11927    }
11928
11929    #[tokio::test]
11930    async fn pre_resolved_metadata_ignored_after_resolution() {
11931        // Create a .torrent-based handle (already in Downloading state).
11932        let data = vec![0xAB; 32768];
11933        let meta = make_test_torrent(&data, 16384);
11934        let info_hash = meta.info_hash;
11935        let storage = make_storage(&data, 16384);
11936        let config = test_config();
11937
11938        let (atx, amask) = test_alert_channel();
11939        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
11940        let handle = TorrentHandle::from_torrent(
11941            meta,
11942            irontide_core::TorrentVersion::V1Only,
11943            None,
11944            dh,
11945            dm,
11946            config,
11947            test_dht_rx(),
11948            test_dht_rx(),
11949            None,
11950            None,
11951            crate::slot_tuner::SlotTuner::disabled(4),
11952            atx,
11953            amask,
11954            None,
11955            None,
11956            test_ban_manager(),
11957            test_ip_filter(),
11958            Arc::new(Vec::new()),
11959            None,
11960            None,
11961            Arc::new(crate::transport::NetworkFactory::tokio()),
11962            None,
11963            Arc::new(crate::stats::SessionCounters::new()),
11964        )
11965        .await
11966        .unwrap();
11967
11968        let stats_before = handle.stats().await.unwrap();
11969        assert_eq!(stats_before.state, TorrentState::Downloading);
11970
11971        // Send pre-resolved metadata — should be silently ignored since
11972        // the actor is already past FetchingMetadata.
11973        let (info_bytes, _) = make_test_info_bytes();
11974        handle.send_pre_resolved_metadata(info_bytes, vec![]);
11975
11976        // Give the actor time to process (or ignore) the command.
11977        tokio::time::sleep(Duration::from_millis(100)).await;
11978
11979        // Verify state hasn't changed and no crash occurred.
11980        let stats_after = handle.stats().await.unwrap();
11981        assert_eq!(stats_after.state, TorrentState::Downloading);
11982        assert_eq!(stats_after.pieces_total, stats_before.pieces_total);
11983
11984        handle.shutdown().await.unwrap();
11985    }
11986
11987    #[tokio::test]
11988    async fn pre_resolved_metadata_with_invalid_hash_stays_fetching() {
11989        // Build info bytes with a WRONG info_hash — the SHA1 won't match
11990        // the magnet link's info_hash, so try_assemble_metadata should
11991        // fail verification and the actor should stay in FetchingMetadata.
11992        let (info_bytes, _correct_hash) = make_test_info_bytes();
11993
11994        // Use a different (wrong) info_hash for the magnet.
11995        let wrong_hash = Id20::from_hex("0000000000000000000000000000000000000001").unwrap();
11996        let handle = create_magnet_handle(wrong_hash).await;
11997
11998        let stats = handle.stats().await.unwrap();
11999        assert_eq!(stats.state, TorrentState::FetchingMetadata);
12000
12001        // Send metadata with mismatched hash — should fail verification.
12002        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12003
12004        tokio::time::sleep(Duration::from_millis(200)).await;
12005
12006        // Actor should remain in FetchingMetadata (verification failed).
12007        let stats = handle.stats().await.unwrap();
12008        assert_eq!(
12009            stats.state,
12010            TorrentState::FetchingMetadata,
12011            "should stay in FetchingMetadata when info_hash doesn't match"
12012        );
12013
12014        handle.shutdown().await.unwrap();
12015    }
12016
12017    #[test]
12018    fn initial_queue_depth_is_128() {
12019        use crate::peer_shared::INITIAL_QUEUE_DEPTH;
12020        assert_eq!(INITIAL_QUEUE_DEPTH, 128);
12021    }
12022
12023    // ---- M159: seed mode scheduling-suppression integration test ----
12024
12025    /// End-to-end test that seed mode actually suppresses new block request
12026    /// dispatch at the wire level.
12027    ///
12028    /// 1. Spin up a 2-piece torrent with no downloaded data.
12029    /// 2. Connect a mock seeder that advertises both pieces.
12030    /// 3. Wait for the actor to send at least one `Request` (normal dispatch).
12031    /// 4. Flip `set_seed_mode(true)`.
12032    /// 5. Observe that a `Cancel` is sent for the pending request, and that
12033    ///    no additional `Request` messages arrive within 500 ms.
12034    /// 6. Confirm the stats snapshot reflects `user_seed_mode == true`.
12035    #[tokio::test]
12036    #[allow(
12037        clippy::large_stack_arrays,
12038        reason = "test data buffer passed directly to make_storage"
12039    )]
12040    async fn m159_seed_mode_suppresses_new_requests_on_wire() {
12041        let data = vec![0xAB; 32768]; // 32 KiB
12042        let meta = make_test_torrent(&data, 16384); // 2 pieces
12043        let info_hash = meta.info_hash;
12044        // Leecher has empty storage — wants both pieces.
12045        let storage = make_storage(&[0u8; 32768], 16384);
12046
12047        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12048        let listen_addr = listener.local_addr().unwrap();
12049        let config = TorrentConfig {
12050            listen_port: listen_addr.port(),
12051            ..test_config()
12052        };
12053        drop(listener);
12054
12055        let (atx, amask) = test_alert_channel();
12056        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12057        let handle = TorrentHandle::from_torrent(
12058            meta,
12059            irontide_core::TorrentVersion::V1Only,
12060            None,
12061            dh,
12062            dm,
12063            config,
12064            test_dht_rx(),
12065            test_dht_rx(),
12066            None,
12067            None,
12068            crate::slot_tuner::SlotTuner::disabled(4),
12069            atx,
12070            amask,
12071            None,
12072            None,
12073            test_ban_manager(),
12074            test_ip_filter(),
12075            Arc::new(Vec::new()),
12076            None,
12077            None,
12078            Arc::new(crate::transport::NetworkFactory::tokio()),
12079            None,
12080            Arc::new(crate::stats::SessionCounters::new()),
12081        )
12082        .await
12083        .unwrap();
12084
12085        tokio::time::sleep(Duration::from_millis(50)).await;
12086
12087        // Connect a mock seeder to the actor's listener.
12088        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12089        let (reader, writer) = tokio::io::split(stream);
12090        let mut writer = writer;
12091        let mut reader = reader;
12092
12093        let hs = Handshake::new(
12094            info_hash,
12095            Id20::from_hex("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
12096        );
12097        writer.write_all(&hs.to_bytes()).await.unwrap();
12098        writer.flush().await.unwrap();
12099        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12100        reader.read_exact(&mut hs_buf).await.unwrap();
12101
12102        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12103        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12104
12105        // Drain the actor's ext handshake, then send ours.
12106        let _actor_ext_hs = framed_read.next().await;
12107        let ext_hs = ExtHandshake::new();
12108        let ext_payload = ext_hs.to_bytes().unwrap();
12109        framed_write
12110            .send(Message::Extended {
12111                ext_id: 0,
12112                payload: ext_payload,
12113            })
12114            .await
12115            .unwrap();
12116
12117        // Announce that we (the mock seeder) have both pieces.
12118        let mut bf = Bitfield::new(2);
12119        bf.set(0);
12120        bf.set(1);
12121        framed_write
12122            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12123            .await
12124            .unwrap();
12125        framed_write.send(Message::Unchoke).await.unwrap();
12126
12127        // Wait for the actor to send its first Request (and any adjacent ones
12128        // inside one select tick). This confirms the normal dispatch path is
12129        // engaged before we flip into seed mode.
12130        let mut initial_request_seen = false;
12131        let wait_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12132        loop {
12133            let remaining = wait_deadline.saturating_duration_since(tokio::time::Instant::now());
12134            if remaining.is_zero() {
12135                break;
12136            }
12137            match tokio::time::timeout(remaining, framed_read.next()).await {
12138                Ok(Some(Ok(Message::Request { .. }))) => {
12139                    initial_request_seen = true;
12140                    break;
12141                }
12142                Ok(Some(Ok(_))) => {}
12143                _ => break,
12144            }
12145        }
12146        assert!(
12147            initial_request_seen,
12148            "actor should have sent a Request before seed mode toggle"
12149        );
12150
12151        // Flip user seed mode on. From this point forward the actor must not
12152        // dispatch any new Request messages.
12153        handle.set_seed_mode(true).await.unwrap();
12154
12155        // There's an inherent race between the actor processing the toggle
12156        // and the per-peer requester loop receiving its `DispatchCommand::Stop`
12157        // — a block may already be in the writer's queue when we flip. Drain
12158        // for a brief grace window, then verify the dispatch has fully halted
12159        // for a second longer window: if scheduling is truly suppressed, no
12160        // Request messages will arrive during the steady-state window.
12161        let grace_deadline = tokio::time::Instant::now() + Duration::from_millis(200);
12162        let mut cancel_seen = false;
12163        let mut grace_requests = 0u32;
12164        loop {
12165            let remaining = grace_deadline.saturating_duration_since(tokio::time::Instant::now());
12166            if remaining.is_zero() {
12167                break;
12168            }
12169            match tokio::time::timeout(remaining, framed_read.next()).await {
12170                Ok(Some(Ok(Message::Request { .. }))) => {
12171                    grace_requests += 1;
12172                }
12173                Ok(Some(Ok(Message::Cancel { .. }))) => {
12174                    cancel_seen = true;
12175                }
12176                Ok(Some(Ok(_))) => {}
12177                Ok(None | Some(Err(_))) | Err(_) => break,
12178            }
12179        }
12180        let _ = (cancel_seen, grace_requests);
12181
12182        // Steady-state window: if the dispatch path is really gated, zero
12183        // new Request messages must arrive for the next 500 ms.
12184        let steady_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
12185        let mut steady_requests = 0u32;
12186        loop {
12187            let remaining = steady_deadline.saturating_duration_since(tokio::time::Instant::now());
12188            if remaining.is_zero() {
12189                break;
12190            }
12191            match tokio::time::timeout(remaining, framed_read.next()).await {
12192                Ok(Some(Ok(Message::Request { .. }))) => {
12193                    steady_requests += 1;
12194                }
12195                Ok(Some(Ok(_))) => {}
12196                Ok(None | Some(Err(_))) | Err(_) => break,
12197            }
12198        }
12199
12200        assert_eq!(
12201            steady_requests, 0,
12202            "after the Stop propagation grace window, no new Request messages \
12203             must appear during steady-state while user_seed_mode is active"
12204        );
12205
12206        // Stats should reflect the flag.
12207        let stats = handle.stats().await.unwrap();
12208        assert!(
12209            stats.user_seed_mode,
12210            "stats.user_seed_mode should be true after set_seed_mode(true)"
12211        );
12212
12213        handle.shutdown().await.unwrap();
12214    }
12215
12216    // ---- M159 Task 1: Wire-level test — uploads continue in seed mode ----
12217    //
12218    // The point of user seed mode is to stop *downloading* (suppress new
12219    // block requests we issue to peers) while still *uploading* (honouring
12220    // incoming `Request` messages from peers who want pieces we have).
12221    // The companion test `m159_seed_mode_suppresses_new_requests_on_wire`
12222    // covers the download-suppression half; this one closes the loop by
12223    // asserting that the upload path survives a seed-mode toggle.
12224    //
12225    // Test shape:
12226    //   1. Pre-seed storage with two verified pieces (actor starts in
12227    //      `Seeding` state because `make_seeded_storage` writes the full
12228    //      dataset before the actor runs initial verification).
12229    //   2. Flip `user_seed_mode` on via `set_seed_mode(true)`. This is the
12230    //      load-bearing step — uploads must still work *after* seed mode
12231    //      is enabled.
12232    //   3. Connect a fake leecher via a real `TcpListener`, complete the
12233    //      BT + extended handshake.
12234    //   4. Announce an empty bitfield and send `Interested`. The choker
12235    //      still runs in seed mode, so the actor must respond with
12236    //      `Unchoke` (seed-mode choking algorithms unchoke interested
12237    //      peers based on upload throughput — a brand-new peer that just
12238    //      sent Interested is a valid candidate).
12239    //   5. Send `Request { index: 0, begin: 0, length: 16384 }` and assert
12240    //      a matching `Piece` message arrives on the wire within 2s, with
12241    //      a payload of the correct length and filled with the pre-seeded
12242    //      byte pattern.
12243    #[tokio::test]
12244    async fn m159_seed_mode_uploads_continue_on_wire() {
12245        const FILL_BYTE: u8 = 0x5A;
12246        const PIECE_LENGTH: u64 = 16384;
12247        const TOTAL_LEN: usize = 32768; // 2 pieces
12248
12249        let data = vec![FILL_BYTE; TOTAL_LEN];
12250        let meta = make_test_torrent(&data, PIECE_LENGTH);
12251        let info_hash = meta.info_hash;
12252        // Pre-seeded storage — actor transitions to Seeding after verify.
12253        let storage = make_seeded_storage(&data, PIECE_LENGTH);
12254
12255        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12256        let listen_addr = listener.local_addr().unwrap();
12257        let config = TorrentConfig {
12258            listen_port: listen_addr.port(),
12259            ..test_config()
12260        };
12261        drop(listener);
12262
12263        let (atx, amask) = test_alert_channel();
12264        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12265        let handle = TorrentHandle::from_torrent(
12266            meta,
12267            irontide_core::TorrentVersion::V1Only,
12268            None,
12269            dh,
12270            dm,
12271            config,
12272            test_dht_rx(),
12273            test_dht_rx(),
12274            None,
12275            None,
12276            crate::slot_tuner::SlotTuner::disabled(4),
12277            atx,
12278            amask,
12279            None,
12280            None,
12281            test_ban_manager(),
12282            test_ip_filter(),
12283            Arc::new(Vec::new()),
12284            None,
12285            None,
12286            Arc::new(crate::transport::NetworkFactory::tokio()),
12287            None,
12288            Arc::new(crate::stats::SessionCounters::new()),
12289        )
12290        .await
12291        .unwrap();
12292
12293        // Wait for initial verification to complete so the actor is really
12294        // in Seeding state before we flip seed mode. Poll stats up to 3s.
12295        let seeding_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12296        loop {
12297            tokio::time::sleep(Duration::from_millis(50)).await;
12298            let stats = handle.stats().await.unwrap();
12299            if stats.state == TorrentState::Seeding && stats.pieces_have == 2 {
12300                break;
12301            }
12302            if tokio::time::Instant::now() > seeding_deadline {
12303                let stats = handle.stats().await.unwrap();
12304                panic!(
12305                    "actor did not reach Seeding state within 3s: state={:?}, have={}/{}",
12306                    stats.state, stats.pieces_have, stats.pieces_total
12307                );
12308            }
12309        }
12310
12311        // Flip user seed mode on. The upload path must continue to serve
12312        // incoming Request messages from this point forward.
12313        handle.set_seed_mode(true).await.unwrap();
12314        let stats = handle.stats().await.unwrap();
12315        assert!(
12316            stats.user_seed_mode,
12317            "stats.user_seed_mode should be true after set_seed_mode(true)"
12318        );
12319
12320        // Connect a mock leecher to the actor's listener.
12321        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12322        let (reader, writer) = tokio::io::split(stream);
12323        let mut writer = writer;
12324        let mut reader = reader;
12325
12326        let hs = Handshake::new(
12327            info_hash,
12328            Id20::from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap(),
12329        );
12330        writer.write_all(&hs.to_bytes()).await.unwrap();
12331        writer.flush().await.unwrap();
12332        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12333        reader.read_exact(&mut hs_buf).await.unwrap();
12334
12335        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12336        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12337
12338        // Drain the actor's ext handshake, then send ours.
12339        let _actor_ext_hs = framed_read.next().await;
12340        let ext_hs = ExtHandshake::new();
12341        let ext_payload = ext_hs.to_bytes().unwrap();
12342        framed_write
12343            .send(Message::Extended {
12344                ext_id: 0,
12345                payload: ext_payload,
12346            })
12347            .await
12348            .unwrap();
12349
12350        // Tell the actor we (the mock leecher) have nothing.
12351        let bf = Bitfield::new(2);
12352        framed_write
12353            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12354            .await
12355            .unwrap();
12356        framed_write.send(Message::Interested).await.unwrap();
12357
12358        // Wait for Unchoke from the actor. The actor may also send its own
12359        // Bitfield/Have/Extended/Choke/etc.; we drain non-Unchoke messages
12360        // until we see it (or time out).
12361        let unchoke_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12362        let mut saw_unchoke = false;
12363        loop {
12364            let remaining = unchoke_deadline.saturating_duration_since(tokio::time::Instant::now());
12365            if remaining.is_zero() {
12366                break;
12367            }
12368            match tokio::time::timeout(remaining, framed_read.next()).await {
12369                Ok(Some(Ok(Message::Unchoke))) => {
12370                    saw_unchoke = true;
12371                    break;
12372                }
12373                Ok(Some(Ok(_))) => {}
12374                Ok(None | Some(Err(_))) => break,
12375                Err(_elapsed) => break,
12376            }
12377        }
12378        assert!(
12379            saw_unchoke,
12380            "actor should have unchoked the leecher while user_seed_mode is active"
12381        );
12382
12383        // Request piece 0, full 16 KiB block. The actor is seeding with
12384        // seed mode on — it must still serve this upload.
12385        framed_write
12386            .send(Message::Request {
12387                index: 0,
12388                begin: 0,
12389                length: PIECE_LENGTH as u32,
12390            })
12391            .await
12392            .unwrap();
12393
12394        // Expect a Piece message to arrive on the wire with matching
12395        // index/begin and the correct payload. Drain any other messages
12396        // (Have, Bitfield updates, Choke refreshes, etc.) that may arrive
12397        // first.
12398        let piece_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
12399        let mut got_piece = false;
12400        loop {
12401            let remaining = piece_deadline.saturating_duration_since(tokio::time::Instant::now());
12402            if remaining.is_zero() {
12403                break;
12404            }
12405            match tokio::time::timeout(remaining, framed_read.next()).await {
12406                Ok(Some(Ok(Message::Piece {
12407                    index,
12408                    begin,
12409                    data_0,
12410                    data_1,
12411                }))) => {
12412                    assert_eq!(index, 0, "Piece index should match request");
12413                    assert_eq!(begin, 0, "Piece begin should match request");
12414                    let mut payload: Vec<u8> =
12415                        Vec::with_capacity(data_0.len().saturating_add(data_1.len()));
12416                    payload.extend_from_slice(&data_0);
12417                    payload.extend_from_slice(&data_1);
12418                    assert_eq!(
12419                        payload.len(),
12420                        PIECE_LENGTH as usize,
12421                        "Piece payload length should match requested length"
12422                    );
12423                    assert!(
12424                        payload.iter().all(|&b| b == FILL_BYTE),
12425                        "Piece payload should contain the pre-seeded fill byte"
12426                    );
12427                    got_piece = true;
12428                    break;
12429                }
12430                Ok(Some(Ok(_))) => {}
12431                Ok(None | Some(Err(_))) => break,
12432                Err(_elapsed) => break,
12433            }
12434        }
12435        assert!(
12436            got_piece,
12437            "actor should have served a Piece in response to Request while user_seed_mode is active"
12438        );
12439
12440        // Stats should still reflect the seed-mode flag and accumulated
12441        // upload bytes for the one block we served.
12442        let stats = handle.stats().await.unwrap();
12443        assert!(
12444            stats.user_seed_mode,
12445            "stats.user_seed_mode should remain true after serving an upload"
12446        );
12447        assert!(
12448            stats.uploaded >= u64::from(PIECE_LENGTH as u32),
12449            "stats.uploaded should reflect the served block, got {}",
12450            stats.uploaded
12451        );
12452
12453        handle.shutdown().await.unwrap();
12454    }
12455
12456    // ---- M161: info dict, v2 hash, and timestamp tests ----
12457
12458    #[tokio::test]
12459    async fn info_field_populated_for_torrent() {
12460        let data = vec![0xAB; 32768];
12461        let meta = make_test_torrent(&data, 16384);
12462        let storage = make_storage(&data, 16384);
12463        let config = test_config();
12464
12465        let (atx, amask) = test_alert_channel();
12466        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12467        let handle = TorrentHandle::from_torrent(
12468            meta,
12469            irontide_core::TorrentVersion::V1Only,
12470            None,
12471            dh,
12472            dm,
12473            config,
12474            test_dht_rx(),
12475            test_dht_rx(),
12476            None,
12477            None,
12478            crate::slot_tuner::SlotTuner::disabled(4),
12479            atx,
12480            amask,
12481            None,
12482            None,
12483            test_ban_manager(),
12484            test_ip_filter(),
12485            Arc::new(Vec::new()),
12486            None,
12487            None,
12488            Arc::new(crate::transport::NetworkFactory::tokio()),
12489            None,
12490            Arc::new(crate::stats::SessionCounters::new()),
12491        )
12492        .await
12493        .unwrap();
12494
12495        tokio::time::sleep(Duration::from_millis(50)).await;
12496
12497        let rd = handle.save_resume_data().await.unwrap();
12498
12499        // info field must be populated when metadata is available
12500        assert!(rd.info.is_some(), "rd.info should be Some for .torrent");
12501
12502        // The embedded bytes must deserialize back to a valid InfoDict
12503        let info_bytes = rd.info.as_ref().unwrap();
12504        let info: irontide_core::InfoDict =
12505            irontide_bencode::from_bytes(info_bytes).expect("info bytes should deserialize");
12506        assert_eq!(info.name, "test");
12507        assert_eq!(info.piece_length, 16384);
12508
12509        handle.shutdown().await.unwrap();
12510    }
12511
12512    #[tokio::test]
12513    async fn info_hash2_none_for_v1_only() {
12514        let data = vec![0xCD; 16384];
12515        let meta = make_test_torrent(&data, 16384);
12516        let storage = make_storage(&data, 16384);
12517        let config = test_config();
12518
12519        let (atx, amask) = test_alert_channel();
12520        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12521        let handle = TorrentHandle::from_torrent(
12522            meta,
12523            irontide_core::TorrentVersion::V1Only,
12524            None,
12525            dh,
12526            dm,
12527            config,
12528            test_dht_rx(),
12529            test_dht_rx(),
12530            None,
12531            None,
12532            crate::slot_tuner::SlotTuner::disabled(4),
12533            atx,
12534            amask,
12535            None,
12536            None,
12537            test_ban_manager(),
12538            test_ip_filter(),
12539            Arc::new(Vec::new()),
12540            None,
12541            None,
12542            Arc::new(crate::transport::NetworkFactory::tokio()),
12543            None,
12544            Arc::new(crate::stats::SessionCounters::new()),
12545        )
12546        .await
12547        .unwrap();
12548
12549        tokio::time::sleep(Duration::from_millis(50)).await;
12550
12551        let rd = handle.save_resume_data().await.unwrap();
12552
12553        // v1-only torrent must not have a v2 hash
12554        assert!(
12555            rd.info_hash2.is_none(),
12556            "v1-only torrent should have info_hash2 = None"
12557        );
12558
12559        // Timestamps should be populated
12560        assert!(
12561            rd.added_time > 0,
12562            "added_time should be a positive POSIX timestamp"
12563        );
12564
12565        handle.shutdown().await.unwrap();
12566    }
12567
12568    #[tokio::test]
12569    async fn info_none_for_unresolved_magnet() {
12570        let magnet = Magnet {
12571            info_hashes: irontide_core::InfoHashes::v1_only(
12572                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
12573            ),
12574            display_name: Some("magnet-test".into()),
12575            trackers: vec![],
12576            peers: vec![],
12577            selected_files: None,
12578        };
12579        let config = test_config();
12580
12581        let (atx, amask) = test_alert_channel();
12582        let (dm, _dj) = test_disk_manager();
12583        let handle = TorrentHandle::from_magnet(
12584            magnet,
12585            dm,
12586            config,
12587            test_dht_rx(),
12588            test_dht_rx(),
12589            None,
12590            None,
12591            crate::slot_tuner::SlotTuner::disabled(4),
12592            atx,
12593            amask,
12594            None,
12595            None,
12596            test_ban_manager(),
12597            test_ip_filter(),
12598            Arc::new(Vec::new()),
12599            None,
12600            None,
12601            Arc::new(crate::transport::NetworkFactory::tokio()),
12602            None,
12603            Arc::new(crate::stats::SessionCounters::new()),
12604        )
12605        .await
12606        .unwrap();
12607
12608        tokio::time::sleep(Duration::from_millis(50)).await;
12609
12610        let rd = handle.save_resume_data().await.unwrap();
12611
12612        // Unresolved magnet has no metadata, so info must be None
12613        assert!(
12614            rd.info.is_none(),
12615            "unresolved magnet should have info = None"
12616        );
12617
12618        // added_time should still be set even for magnets
12619        assert!(
12620            rd.added_time > 0,
12621            "added_time should be set for magnet links"
12622        );
12623
12624        handle.shutdown().await.unwrap();
12625    }
12626
12627    // ---- v0.173.1: TorrentCommand::GetMeta tests (Class A architectural fix) ----
12628
12629    #[tokio::test]
12630    async fn torrent_command_get_meta_returns_none_before_metadata() {
12631        // v0.173.1: pre-metadata magnet handles must return None from GetMeta.
12632        let info_hash =
12633            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").expect("valid hex");
12634        let handle = create_magnet_handle(info_hash).await;
12635
12636        let (tx, rx) = oneshot::channel();
12637        handle
12638            .cmd_tx
12639            .send(TorrentCommand::GetMeta { reply: tx })
12640            .await
12641            .expect("cmd_tx send");
12642        let result = rx.await.expect("GetMeta reply");
12643        assert!(
12644            result.is_none(),
12645            "pre-metadata magnet must return None from GetMeta"
12646        );
12647
12648        handle.shutdown().await.unwrap();
12649    }
12650
12651    #[tokio::test]
12652    async fn torrent_command_get_meta_returns_some_after_metadata() {
12653        // v0.173.1: once metadata is assembled (via PreResolvedMetadata push),
12654        // GetMeta must return Some(meta) with the matching info hash.
12655        let (info_bytes, info_hash) = make_test_info_bytes();
12656        let handle = create_magnet_handle(info_hash).await;
12657
12658        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12659
12660        // Poll GetMeta until it returns Some or we exceed a 2s budget — the
12661        // PreResolvedMetadata command runs through the actor select! loop
12662        // asynchronously so we can't rely on a hard sleep.
12663        let mut result = None;
12664        for _ in 0..100 {
12665            tokio::time::sleep(Duration::from_millis(20)).await;
12666            let (tx, rx) = oneshot::channel();
12667            handle
12668                .cmd_tx
12669                .send(TorrentCommand::GetMeta { reply: tx })
12670                .await
12671                .expect("cmd_tx send");
12672            let r = rx.await.expect("GetMeta reply");
12673            if r.is_some() {
12674                result = r;
12675                break;
12676            }
12677        }
12678        let meta = result.expect("GetMeta must return Some after PreResolvedMetadata");
12679        assert_eq!(meta.info_hash, info_hash);
12680
12681        handle.shutdown().await.unwrap();
12682    }
12683
12684    // ── M178 Lane B1: WebSeedStats actor state machine ───────────────
12685
12686    #[tokio::test]
12687    async fn web_seed_progress_idle_to_active_on_first_success() {
12688        let mut actor = TorrentActor::for_throttle_test(8, 0);
12689        actor.handle_web_seed_progress("http://seed.example/file", 1024, 1_000_000, None);
12690        let stats = actor
12691            .web_seed_stats
12692            .get("http://seed.example/file")
12693            .expect("stats inserted");
12694        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12695        assert_eq!(stats.downloaded_bytes, 1024);
12696        assert_eq!(stats.last_rate_bps, 1_000_000);
12697        assert_eq!(stats.consecutive_failures, 0);
12698        assert!(stats.last_attempt_unix_secs > 0);
12699        assert!(actor.need_save_resume);
12700    }
12701
12702    #[tokio::test]
12703    async fn web_seed_progress_active_to_errored_then_recovery_persists_last_error() {
12704        let mut actor = TorrentActor::for_throttle_test(8, 0);
12705        let url = "http://seed.example/file".to_string();
12706
12707        // 1) Initial success → Active
12708        actor.handle_web_seed_progress(&url, 1024, 100, None);
12709        assert_eq!(
12710            actor.web_seed_stats[&url].state,
12711            irontide_core::WebSeedState::Active
12712        );
12713
12714        // 2) Failure → Errored, last_error populated
12715        actor.handle_web_seed_progress(&url, 1024, 0, Some("503".into()));
12716        let stats = &actor.web_seed_stats[&url];
12717        assert_eq!(stats.state, irontide_core::WebSeedState::Errored);
12718        assert_eq!(stats.last_error.as_deref(), Some("503"));
12719        assert_eq!(stats.consecutive_failures, 1);
12720
12721        // 3) Recovery → Active, but last_error PERSISTS (Issue 2.2)
12722        actor.handle_web_seed_progress(&url, 2048, 200, None);
12723        let stats = &actor.web_seed_stats[&url];
12724        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12725        assert_eq!(
12726            stats.last_error.as_deref(),
12727            Some("503"),
12728            "last_error must persist through recovery (D-eng-8)"
12729        );
12730        assert_eq!(
12731            stats.consecutive_failures, 0,
12732            "consecutive_failures resets on success"
12733        );
12734    }
12735
12736    #[tokio::test]
12737    async fn web_seed_progress_consecutive_failures_monotonic_within_run() {
12738        let mut actor = TorrentActor::for_throttle_test(8, 0);
12739        let url = "http://seed.example/file".to_string();
12740
12741        actor.handle_web_seed_progress(&url, 0, 0, Some("e1".into()));
12742        actor.handle_web_seed_progress(&url, 0, 0, Some("e2".into()));
12743        actor.handle_web_seed_progress(&url, 0, 0, Some("e3".into()));
12744        let stats = &actor.web_seed_stats[&url];
12745        assert_eq!(stats.consecutive_failures, 3);
12746        assert_eq!(
12747            stats.last_error.as_deref(),
12748            Some("e3"),
12749            "last_error reflects most recent message"
12750        );
12751
12752        actor.handle_web_seed_progress(&url, 1024, 100, None);
12753        assert_eq!(
12754            actor.web_seed_stats[&url].consecutive_failures, 0,
12755            "success resets consecutive_failures"
12756        );
12757    }
12758
12759    // ── M178 Lane B3: PeX + LSD peer counters ────────────────────────
12760
12761    /// Inject a `PeerStates` into a synthetic actor so `handle_add_peers`
12762    /// can run without spinning up the full peer pipeline.
12763    fn install_peer_states(actor: &mut TorrentActor) {
12764        let (queue_tx, _queue_rx) = mpsc::unbounded_channel();
12765        actor.peer_states = Some(std::sync::Arc::new(crate::peer_states::PeerStates::new(
12766            queue_tx,
12767        )));
12768    }
12769
12770    fn addr(octet: u8, port: u16) -> std::net::SocketAddr {
12771        std::net::SocketAddr::new(
12772            std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, octet)),
12773            port,
12774        )
12775    }
12776
12777    #[tokio::test]
12778    async fn pex_count_dedups_same_peer_in_two_messages() {
12779        let mut actor = TorrentActor::for_throttle_test(8, 0);
12780        install_peer_states(&mut actor);
12781
12782        // Message 1: peers A and B
12783        actor.handle_add_peers(
12784            vec![addr(1, 6881), addr(2, 6881)],
12785            crate::peer_state::PeerSource::Pex,
12786        );
12787        // Message 2: peer A again, plus C
12788        actor.handle_add_peers(
12789            vec![addr(1, 6881), addr(3, 6881)],
12790            crate::peer_state::PeerSource::Pex,
12791        );
12792        assert_eq!(
12793            actor.pex_peer_count, 3,
12794            "3 unique peers across 2 PEX messages, A counted once"
12795        );
12796        assert_eq!(actor.lsd_peer_count, 0, "LSD untouched");
12797    }
12798
12799    #[tokio::test]
12800    async fn lsd_count_aggregates_across_multicasts() {
12801        let mut actor = TorrentActor::for_throttle_test(8, 0);
12802        install_peer_states(&mut actor);
12803
12804        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Lsd);
12805        actor.handle_add_peers(
12806            vec![addr(2, 6881), addr(3, 6881)],
12807            crate::peer_state::PeerSource::Lsd,
12808        );
12809        actor.handle_add_peers(
12810            vec![addr(1, 6881)], // dup
12811            crate::peer_state::PeerSource::Lsd,
12812        );
12813        assert_eq!(actor.lsd_peer_count, 3);
12814    }
12815
12816    #[tokio::test]
12817    async fn other_sources_do_not_bump_pex_or_lsd() {
12818        let mut actor = TorrentActor::for_throttle_test(8, 0);
12819        install_peer_states(&mut actor);
12820
12821        actor.handle_add_peers(
12822            vec![addr(1, 6881), addr(2, 6881)],
12823            crate::peer_state::PeerSource::Tracker,
12824        );
12825        actor.handle_add_peers(vec![addr(3, 6881)], crate::peer_state::PeerSource::Dht);
12826        actor.handle_add_peers(vec![addr(4, 6881)], crate::peer_state::PeerSource::Incoming);
12827        assert_eq!(actor.pex_peer_count, 0);
12828        assert_eq!(actor.lsd_peer_count, 0);
12829    }
12830
12831    #[tokio::test]
12832    async fn dedup_runs_against_global_seen_set() {
12833        // A peer first observed via tracker won't recount when later
12834        // re-announced via PEX, because the seen-set is shared across
12835        // sources. This is the intended behaviour: PEX/LSD counts measure
12836        // *new* peer discoveries from those subsystems, not redundant
12837        // re-announcements.
12838        let mut actor = TorrentActor::for_throttle_test(8, 0);
12839        install_peer_states(&mut actor);
12840
12841        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Tracker);
12842        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Pex);
12843        assert_eq!(
12844            actor.pex_peer_count, 0,
12845            "peer already seen via tracker — PEX shouldn't re-count"
12846        );
12847    }
12848
12849    #[tokio::test]
12850    async fn web_seed_progress_dirties_resume_flag() {
12851        let mut actor = TorrentActor::for_throttle_test(8, 0);
12852        actor.need_save_resume = false;
12853        actor.handle_web_seed_progress("http://x/file", 100, 50, None);
12854        assert!(
12855            actor.need_save_resume,
12856            "every progress event should mark fast-resume dirty"
12857        );
12858    }
12859
12860    #[tokio::test]
12861    async fn paused_torrent_rejects_outbound_peer_connect() {
12862        let mut actor = TorrentActor::for_throttle_test(8, 0);
12863        install_peer_states(&mut actor);
12864        actor.state = TorrentState::Paused;
12865
12866        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12867        let permit = sem.clone().acquire_owned().await.unwrap();
12868        let connect = crate::peer_adder::ConnectPeer {
12869            addr: addr(1, 6881),
12870            source: crate::peer_state::PeerSource::Dht,
12871            permit,
12872        };
12873        actor.handle_adder_connect(connect);
12874        assert!(
12875            actor.peers.is_empty(),
12876            "paused torrent must not accept outbound peer connections"
12877        );
12878        assert_eq!(
12879            sem.available_permits(),
12880            1,
12881            "semaphore permit must be released on rejection"
12882        );
12883    }
12884
12885    #[tokio::test]
12886    async fn resume_from_queued_restores_fetching_metadata_for_magnets() {
12887        let mut actor = TorrentActor::for_throttle_test(0, 0);
12888        actor.state = TorrentState::Queued;
12889        assert!(
12890            actor.chunk_tracker.is_none(),
12891            "magnet torrent has no chunk tracker before metadata"
12892        );
12893        assert_eq!(actor.num_pieces, 0);
12894
12895        actor.handle_resume().await;
12896        assert_eq!(
12897            actor.state,
12898            TorrentState::FetchingMetadata,
12899            "magnet torrent must resume to FetchingMetadata, not Downloading"
12900        );
12901    }
12902
12903    #[tokio::test]
12904    async fn resume_from_queued_restores_downloading_when_metadata_known() {
12905        let mut actor = TorrentActor::for_throttle_test(8, 0);
12906        actor.state = TorrentState::Queued;
12907
12908        actor.handle_resume().await;
12909        assert_eq!(
12910            actor.state,
12911            TorrentState::Downloading,
12912            "torrent with known pieces must resume to Downloading"
12913        );
12914    }
12915
12916    #[tokio::test]
12917    async fn queued_torrent_rejects_outbound_peer_connect() {
12918        let mut actor = TorrentActor::for_throttle_test(8, 0);
12919        install_peer_states(&mut actor);
12920        actor.state = TorrentState::Queued;
12921
12922        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12923        let permit = sem.clone().acquire_owned().await.unwrap();
12924        let connect = crate::peer_adder::ConnectPeer {
12925            addr: addr(1, 6881),
12926            source: crate::peer_state::PeerSource::Dht,
12927            permit,
12928        };
12929        actor.handle_adder_connect(connect);
12930        assert!(
12931            actor.peers.is_empty(),
12932            "queued torrent must not accept outbound peer connections"
12933        );
12934        assert_eq!(
12935            sem.available_permits(),
12936            1,
12937            "semaphore permit must be released on rejection"
12938        );
12939    }
12940
12941    /// Inject a synthetic `PeerState` directly into `actor.peers` so
12942    /// `disconnect_peer` exercises the flush path without spinning up
12943    /// real peer tasks.
12944    fn inject_peer_for_flush(
12945        actor: &mut TorrentActor,
12946        peer_addr: std::net::SocketAddr,
12947        unchoke_started: Option<std::time::Instant>,
12948        prior_total: std::time::Duration,
12949    ) {
12950        let (cmd_tx, _cmd_rx) = mpsc::channel(8);
12951        let mut peer = crate::peer_state::PeerState::new(
12952            peer_addr,
12953            actor.num_pieces,
12954            cmd_tx,
12955            crate::peer_state::PeerSource::Tracker,
12956            Arc::new(AtomicU32::new(0)),
12957            Arc::new(AtomicU32::new(128)),
12958            Arc::new(tokio::sync::Notify::new()),
12959        );
12960        peer.am_unchoke_started_at = unchoke_started;
12961        peer.unchoke_duration_total = prior_total;
12962        actor.peers.insert(peer_addr, peer);
12963    }
12964
12965    #[tokio::test]
12966    async fn disconnect_while_unchoked_flushes_delta_into_torrent_map() {
12967        let mut actor = TorrentActor::for_throttle_test(8, 0);
12968        let p = addr(1, 6881);
12969
12970        // Seed the peer with an in-flight unchoke window opened ~50 ms ago
12971        // and a pre-existing 100 ms accumulator from prior toggles.
12972        inject_peer_for_flush(
12973            &mut actor,
12974            p,
12975            Some(std::time::Instant::now() - std::time::Duration::from_millis(50)),
12976            std::time::Duration::from_millis(100),
12977        );
12978
12979        actor.disconnect_peer(p, "test");
12980
12981        let total = actor
12982            .unchoke_durations
12983            .get(&p)
12984            .copied()
12985            .expect("disconnect must flush a non-zero delta into the torrent map");
12986        assert!(
12987            total >= std::time::Duration::from_millis(140),
12988            "expected ≥140 ms (100 prior + ~50 in-flight), got {total:?}"
12989        );
12990    }
12991
12992    #[tokio::test]
12993    async fn disconnect_then_reconnect_preserves_history() {
12994        let mut actor = TorrentActor::for_throttle_test(8, 0);
12995        let p = addr(2, 6881);
12996
12997        // First connection: 80 ms unchoke window already accumulated.
12998        inject_peer_for_flush(&mut actor, p, None, std::time::Duration::from_millis(80));
12999        actor.disconnect_peer(p, "test");
13000        let after_first = *actor
13001            .unchoke_durations
13002            .get(&p)
13003            .expect("first flush must populate the entry");
13004        assert_eq!(after_first, std::time::Duration::from_millis(80));
13005
13006        // Reconnect: peer rejoins with a fresh in-flight window.
13007        inject_peer_for_flush(
13008            &mut actor,
13009            p,
13010            Some(std::time::Instant::now() - std::time::Duration::from_millis(40)),
13011            std::time::Duration::ZERO,
13012        );
13013        actor.disconnect_peer(p, "test");
13014        let after_second = *actor.unchoke_durations.get(&p).unwrap();
13015        assert!(
13016            after_second >= std::time::Duration::from_millis(120),
13017            "second flush must add to the existing entry, got {after_second:?}"
13018        );
13019    }
13020
13021    // -- M187 Fix B: piece-verified wakes reservation_notify --
13022
13023    #[tokio::test]
13024    async fn piece_verified_wakes_reservation_notify() {
13025        let mut actor = TorrentActor::for_throttle_test(8, 0);
13026        let notify = Arc::new(tokio::sync::Notify::new());
13027        actor.reservation_notify = Some(Arc::clone(&notify));
13028
13029        let notified = notify.notified();
13030        tokio::pin!(notified);
13031        assert!(
13032            futures::poll!(&mut notified).is_pending(),
13033            "notify should not have fired yet"
13034        );
13035
13036        actor.on_piece_verified(0).await;
13037
13038        tokio::time::timeout(Duration::from_secs(1), notified)
13039            .await
13040            .expect("reservation_notify must be woken by on_piece_verified");
13041    }
13042
13043    // -- 2026-05-11 state-gated pipeline-tick safety-net wake --
13044
13045    /// Helper: construct an actor that already has a `PieceTracker` with the
13046    /// given (`queue_count`, `inflight_count`). The tracker starts empty and
13047    /// we mark pieces wanted/reserved as needed to land on the target shape.
13048    fn actor_with_tracker_state(queue: u32, inflight: u32) -> TorrentActor {
13049        use crate::piece_reservation::PieceTracker;
13050        use irontide_storage::Bitfield;
13051        let mut actor = TorrentActor::for_throttle_test(8, 0);
13052        let num_pieces = queue + inflight + 1;
13053        let we_have = Bitfield::new(num_pieces);
13054        let mut wanted = Bitfield::new(num_pieces);
13055        for i in 0..num_pieces {
13056            wanted.set(i);
13057        }
13058        let mut pt = PieceTracker::new(num_pieces, &we_have, &wanted);
13059        // Trim the queue down to `queue` (the rest become "completed" by
13060        // marking them unwanted, which clears them from queue_pieces).
13061        for i in queue..num_pieces {
13062            pt.mark_unwanted(i);
13063        }
13064        // Move `inflight` pieces from queue to inflight via record_reservation.
13065        for i in 0..inflight {
13066            pt.record_reservation(i, "10.0.0.1:6881".parse().unwrap());
13067        }
13068        // After this: queue_count() == queue - inflight, inflight_count() == inflight.
13069        // We started with `queue` wanted pieces, then reserved `inflight` of
13070        // them, leaving (queue - inflight) in the queue. Adjust caller-facing
13071        // semantics so the helper's name matches the assertion.
13072        actor.piece_tracker = Some(pt);
13073        actor
13074    }
13075
13076    #[tokio::test]
13077    async fn pipeline_tick_skips_wake_when_dispatch_state_unchanged() {
13078        let mut actor = actor_with_tracker_state(10, 3);
13079        let notify = Arc::new(tokio::sync::Notify::new());
13080        actor.reservation_notify = Some(Arc::clone(&notify));
13081
13082        // First tick seeds the baseline and always wakes — this matches the
13083        // helper's documented first-call semantics. Drop the baseline wake
13084        // by polling once before installing the real test waiter.
13085        actor.tick_dispatch_safety_wake();
13086        let _drain = notify.notified();
13087
13088        // No dispatch state change between this tick and the next.
13089        let notified = notify.notified();
13090        tokio::pin!(notified);
13091        actor.tick_dispatch_safety_wake();
13092
13093        // Give tokio a chance to dispatch any pending wakes before asserting.
13094        tokio::task::yield_now().await;
13095        assert!(
13096            futures::poll!(&mut notified).is_pending(),
13097            "tick must not wake when (queue_count, inflight_count) is unchanged"
13098        );
13099        // And the skip counter increments.
13100        let skipped = actor.counters.get(crate::stats::DISPATCH_TICK_WAKE_SKIPPED);
13101        assert!(
13102            skipped >= 1,
13103            "expected DISPATCH_TICK_WAKE_SKIPPED >= 1, got {skipped}"
13104        );
13105    }
13106
13107    #[tokio::test]
13108    async fn pipeline_tick_wakes_when_inflight_changes() {
13109        let mut actor = actor_with_tracker_state(10, 3);
13110        let notify = Arc::new(tokio::sync::Notify::new());
13111        actor.reservation_notify = Some(Arc::clone(&notify));
13112
13113        // Seed baseline.
13114        actor.tick_dispatch_safety_wake();
13115
13116        // Mutate dispatch state: reserve another piece via the tracker. This
13117        // changes both queue_count (down 1) and inflight_count (up 1).
13118        if let Some(ref mut pt) = actor.piece_tracker {
13119            pt.record_reservation(5, "10.0.0.2:6881".parse().unwrap());
13120        }
13121
13122        let notified = notify.notified();
13123        tokio::pin!(notified);
13124        actor.tick_dispatch_safety_wake();
13125
13126        tokio::time::timeout(Duration::from_secs(1), notified)
13127            .await
13128            .expect("tick must wake when dispatch state changed");
13129    }
13130}