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        // M254: honour at-add priorities; empty config vec = all-Normal.
291        let mut file_priorities = config.file_priorities.clone();
292        file_priorities.resize(file_lengths.len(), FilePriority::Normal);
293        let wanted_pieces =
294            crate::piece_selector::build_wanted_pieces(&file_priorities, &file_lengths, &lengths);
295
296        let (cmd_tx, cmd_rx) = mpsc::channel(256);
297        let (event_tx, event_rx) = mpsc::channel(2048);
298        let (write_error_tx, write_error_rx) = mpsc::channel(64);
299        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
300        let (hash_result_tx, hash_result_rx) = mpsc::channel(64); // M96
301        let our_peer_id = if config.anonymous_mode {
302            PeerId::generate_anonymous().0
303        } else {
304            PeerId::generate().0
305        };
306
307        // Bind listener for incoming connections
308        // Try dual-stack [::]:port first, fall back to IPv4-only
309        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
310            .bind_tcp(SocketAddr::from((
311                std::net::Ipv6Addr::UNSPECIFIED,
312                config.listen_port,
313            )))
314            .await
315        {
316            Ok(l) => Some(l),
317            Err(_) => factory
318                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
319                .await
320                .ok(),
321        };
322        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
323
324        let mut tracker_manager = TrackerManager::from_torrent_filtered(
325            &meta,
326            our_peer_id,
327            config.listen_port,
328            config.url_security,
329            config.peer_dscp,
330            config.anonymous_mode,
331        );
332        tracker_manager.set_info_hashes(info_hashes.clone());
333
334        // BEP 7: include our I2P destination in tracker announces
335        if let Some(ref sam) = sam_session {
336            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
337        }
338
339        let enable_dht = config.enable_dht;
340
341        // M173 Lane B (B6): snapshot the broadcast at construction time
342        // to seed the initial peer-discovery channels. Future DHT
343        // restarts (B11) deliver peer batches via the watch
344        // subscription, not the initial snapshot.
345        let dht_initial = dht_rx.current();
346        let dht_v6_initial = dht_v6_rx.current();
347
348        // Start DHT peer discovery if enabled and available
349        let dht_peers_rx = if enable_dht {
350            if let Some(ref dht) = dht_initial {
351                match dht.get_peers(meta.info_hash).await {
352                    Ok(rx) => Some(rx),
353                    Err(e) => {
354                        warn!("failed to start DHT v4 get_peers: {e}");
355                        None
356                    }
357                }
358            } else {
359                None
360            }
361        } else {
362            None
363        };
364
365        let dht_v6_peers_rx = if enable_dht {
366            if let Some(ref dht6) = dht_v6_initial {
367                match dht6.get_peers(meta.info_hash).await {
368                    Ok(rx) => Some(rx),
369                    Err(e) => {
370                        debug!("failed to start DHT v6 get_peers: {e}");
371                        None
372                    }
373                }
374            } else {
375                None
376            }
377        } else {
378            None
379        };
380
381        // Dual-swarm: also search for v2 hash peers if hybrid
382        let v2_as_v1 = if info_hashes.is_hybrid() {
383            info_hashes
384                .v2
385                .map(|v2| Id20(v2.0[..20].try_into().unwrap()))
386        } else {
387            None
388        };
389        let (dht_v2_peers_rx, dht_v6_v2_peers_rx) =
390            if let (true, Some(v2_id)) = (enable_dht, v2_as_v1) {
391                let rx4 = if let Some(ref dht) = dht_initial {
392                    dht.get_peers(v2_id).await.ok()
393                } else {
394                    None
395                };
396                let rx6 = if let Some(ref dht6) = dht_v6_initial {
397                    dht6.get_peers(v2_id).await.ok()
398                } else {
399                    None
400                };
401                (rx4, rx6)
402            } else {
403                (None, None)
404            };
405
406        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
407        let download_bucket = Arc::new(parking_lot::Mutex::new(
408            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
409        ));
410        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
411            0,
412            0,
413            0,
414            0,
415            config.upload_rate_limit,
416            config.download_rate_limit,
417        );
418
419        let super_seed = if config.super_seeding {
420            Some(crate::super_seed::SuperSeedState::new())
421        } else {
422            None
423        };
424        // M118: broadcast channel for Have distribution — capacity scales with torrent size
425        let (have_broadcast_tx, _) =
426            tokio::sync::broadcast::channel(std::cmp::max(128, num_pieces as usize / 4));
427        let is_share_mode = config.share_mode;
428
429        let (piece_ready_tx, _) = broadcast::channel(64);
430        let initial_have = chunk_tracker.bitfield().clone();
431        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(initial_have);
432        let stream_read_semaphore =
433            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
434
435        let choker = Choker::with_algorithms(
436            initial_unchoke_slots(config.max_uploads_per_torrent),
437            config.seed_choking_algorithm,
438            config.choking_algorithm,
439            config.upload_rate_limit,
440            2,
441            20,
442        );
443
444        // M96: Wire hash pool into disk handle for V1-only torrents
445        let mut disk = disk;
446        if matches!(version, irontide_core::TorrentVersion::V1Only)
447            && let Some(pool) = &hash_pool
448        {
449            disk.set_hash_pool(pool.clone());
450            disk.set_hash_result_tx(hash_result_tx.clone());
451        }
452
453        // M116: Pre-compute file->piece mapping for zero-alloc completion checks.
454        let cached_files = Some(build_cached_file_info(&meta, &lengths));
455
456        // v0.173.4 (prong 1): seed the snapshot watch with an empty initial
457        let (order_map_tx, _order_map_rx_seed) =
458            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
459
460        let actor = TorrentActor {
461            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
462                config.lock_warn_threshold_ms,
463            ),
464            config,
465            info_hash: meta.info_hash,
466            our_peer_id,
467            state: TorrentState::Downloading,
468            disk: Some(disk),
469            disk_manager,
470            chunk_tracker: Some(chunk_tracker),
471            lengths: Some(lengths),
472            num_pieces,
473            streaming_pieces: BTreeSet::new(),
474            time_critical_pieces: BTreeSet::new(),
475            streaming_cursors: Vec::new(),
476            piece_ready_tx,
477            have_watch_tx,
478            have_watch_rx,
479            stream_read_semaphore,
480            file_priorities,
481            wanted_pieces,
482            end_game: EndGame::new(),
483            peers: HashMap::new(),
484            unchoke_durations: HashMap::new(),
485            cached_peer_rates: FxHashMap::default(),
486            refill_notify: Arc::new(tokio::sync::Notify::new()),
487            atomic_states: None,
488            block_maps: None,
489            steal_candidates: None,
490            last_steal_populate: Instant::now(),
491            piece_write_guards: None,
492            soft_reap_buf: Vec::new(),
493            eviction_history: std::collections::VecDeque::new(),
494            force_immediate_choker_tick: false,
495            piece_tracker: None,
496            order_map_dirty: false,
497            next_order_map_gen: 0,
498            order_map_tx,
499            piece_owner: Vec::new(),
500            peer_slab: crate::piece_reservation::PeerSlab::new(),
501            priority_pieces: BTreeSet::new(),
502            max_in_flight: 512,
503            reservation_notify: None,
504            last_tick_dispatch_state: None,
505            choker,
506            user_seed_mode: false,
507            user_forced: false,
508            max_connections: 0,
509            peer_states: None,
510            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
511            connect_permits: HashMap::new(),
512            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
513                std::collections::HashMap::new(),
514            )),
515            connect_rx: None,
516            metadata_downloader: None,
517            downloaded: 0,
518            uploaded: 0,
519            checking_progress: 0.0,
520            total_download: 0,
521            total_upload: 0,
522            total_failed_bytes: 0,
523            total_redundant_bytes: 0,
524            added_time: std::time::SystemTime::now()
525                .duration_since(std::time::UNIX_EPOCH)
526                .map_or(0, |d| d.as_secs() as i64),
527            completed_time: 0,
528            last_download: 0,
529            last_upload: 0,
530            last_seen_complete: 0,
531            active_duration: 0,
532            finished_duration: 0,
533            seeding_duration: 0,
534            active_since: Some(std::time::Instant::now()),
535            state_duration_since: None,
536            started_at: std::time::Instant::now(),
537            moving_storage: false,
538            has_incoming: false,
539            need_save_resume: false,
540            error: String::new(),
541            error_file: -1,
542            cmd_rx,
543            event_tx,
544            event_rx,
545            write_error_rx,
546            write_error_tx,
547            verify_result_rx,
548            verify_result_tx,
549            pending_verify: HashSet::new(),
550            piece_generations: vec![0u64; num_pieces as usize],
551            hash_result_rx,
552            hash_result_tx,
553            meta: Some(meta),
554            cached_files,
555            listener,
556            utp_socket,
557            utp_socket_v6,
558            tracker_manager,
559            tracker_result_rx: None,
560            dht_rx,
561            dht_v6_rx,
562            dht_enabled: enable_dht,
563            dht_peers_rx,
564            dht_v6_peers_rx,
565            dht_v6_empty_count: 0,
566            dht_v6_last_retry: None,
567            alert_tx,
568            alert_mask,
569            upload_bucket,
570            download_bucket,
571            global_upload_bucket,
572            global_download_bucket,
573            slot_tuner,
574            upload_bytes_interval: 0,
575            peak_download_rate: 0,
576            rechoke_per_min_est: 0.0,
577            web_seeds: HashMap::new(),
578            banned_web_seeds: HashSet::new(),
579            web_seed_in_flight: HashMap::new(),
580            web_seed_stats: HashMap::new(),
581            pex_peer_count: 0,
582            lsd_peer_count: 0,
583            super_seed,
584            have_broadcast_tx,
585            suggested_to_peers: HashMap::new(),
586            predictive_have_sent: HashSet::new(),
587
588            ban_manager,
589            ip_filter,
590            piece_contributors: HashMap::new(),
591            parole_pieces: HashMap::new(),
592            external_ip: None,
593            share_lru: std::collections::VecDeque::new(),
594            share_max_pieces: if is_share_mode { 64 } else { 0 },
595            plugins,
596            hash_picker,
597            version,
598            meta_v2,
599            info_hashes,
600            dht_v2_peers_rx,
601            dht_v6_v2_peers_rx,
602            magnet_selected_files: None,
603            sam_session,
604            i2p_accept_rx: None,
605            i2p_peer_counter: 0,
606            i2p_destinations: HashMap::new(),
607            ssl_manager,
608            rate_limiter_set,
609            auto_sequential_active: false,
610            factory,
611            hash_pool_ref: hash_pool,
612            connect_attempts: 0,
613            connect_failures: 0,
614            choke_rotations: 0,
615            inflight_started: Vec::new(),
616            completed_piece_times: std::collections::VecDeque::new(),
617            piece_steals: 0,
618            holepunch_relayed: 0,
619            holepunch_relay_rate: HashMap::new(),
620            holepunch_cooldowns: HashMap::new(),
621            holepunch_pending: Vec::new(),
622            counters,
623        };
624
625        let spawn_info_hash = actor.info_hash;
626        let join_handle = tokio::spawn(actor.run());
627        // Monitor the actor task so panics/exits are logged instead of silently swallowed.
628        tokio::spawn(async move {
629            match join_handle.await {
630                Ok(()) => {
631                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
632                }
633                Err(e) if e.is_panic() => {
634                    let panic_payload = e.into_panic();
635                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
636                        (*s).to_string()
637                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
638                        s.clone()
639                    } else {
640                        "unknown panic payload".to_string()
641                    };
642                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
643                }
644                Err(e) => {
645                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
646                }
647            }
648        });
649        Ok(Self { cmd_tx })
650    }
651
652    /// Create a torrent session from a magnet link (metadata fetched via BEP 9).
653    ///
654    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
655    /// from the session-level [`irontide_dht::DhtBroadcast`]; see
656    /// [`Self::from_torrent`] for the rationale.
657    ///
658    /// # Errors
659    /// Returns an error if disk registration fails or the torrent actor cannot be started.
660    #[allow(clippy::too_many_arguments)]
661    pub async fn from_magnet(
662        magnet: Magnet,
663        disk_manager: DiskManagerHandle,
664        config: TorrentConfig,
665        dht_rx: irontide_dht::DhtReceiver,
666        dht_v6_rx: irontide_dht::DhtReceiver,
667        global_upload_bucket: Option<SharedBucket>,
668        global_download_bucket: Option<SharedBucket>,
669        slot_tuner: crate::slot_tuner::SlotTuner,
670        alert_tx: broadcast::Sender<Alert>,
671        alert_mask: Arc<AtomicU32>,
672        utp_socket: Option<irontide_utp::UtpSocket>,
673        utp_socket_v6: Option<irontide_utp::UtpSocket>,
674        ban_manager: irontide_session_types::SharedBanManager,
675        ip_filter: irontide_session_types::SharedIpFilter,
676        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
677        sam_session: Option<Arc<crate::i2p::SamSession>>,
678        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
679        factory: Arc<crate::transport::NetworkFactory>,
680        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
681        counters: Arc<crate::stats::SessionCounters>,
682    ) -> crate::Result<Self> {
683        let (cmd_tx, cmd_rx) = mpsc::channel(256);
684        let (event_tx, event_rx) = mpsc::channel(2048);
685        let (write_error_tx, write_error_rx) = mpsc::channel(64);
686        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
687        // M96: Dummy channel — replaced when metadata arrives and num_pieces is known
688        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
689        let our_peer_id = if config.anonymous_mode {
690            PeerId::generate_anonymous().0
691        } else {
692            PeerId::generate().0
693        };
694
695        // Try dual-stack [::]:port first, fall back to IPv4-only
696        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
697            .bind_tcp(SocketAddr::from((
698                std::net::Ipv6Addr::UNSPECIFIED,
699                config.listen_port,
700            )))
701            .await
702        {
703            Ok(l) => Some(l),
704            Err(_) => factory
705                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
706                .await
707                .ok(),
708        };
709        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
710
711        let mut tracker_manager = TrackerManager::empty(
712            magnet.info_hash(),
713            our_peer_id,
714            config.listen_port,
715            config.peer_dscp,
716            config.anonymous_mode,
717        );
718        // Add tracker URLs from the magnet link (BEP 9 §3.1)
719        for url in &magnet.trackers {
720            tracker_manager.add_tracker_url(url);
721        }
722
723        // BEP 7: include our I2P destination in tracker announces
724        if let Some(ref sam) = sam_session {
725            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
726        }
727
728        let enable_dht = config.enable_dht;
729
730        // M173 Lane B (B6): snapshot the broadcast at construction time
731        // for the initial peer-discovery wiring; future DHT restarts
732        // (B11) deliver new handles via the watch subscription.
733        let dht_initial = dht_rx.current();
734        let dht_v6_initial = dht_v6_rx.current();
735
736        // Start DHT peer discovery if enabled and available
737        let dht_peers_rx = if enable_dht {
738            if let Some(ref dht) = dht_initial {
739                match dht.get_peers(magnet.info_hash()).await {
740                    Ok(rx) => Some(rx),
741                    Err(e) => {
742                        warn!("failed to start DHT v4 get_peers: {e}");
743                        None
744                    }
745                }
746            } else {
747                None
748            }
749        } else {
750            None
751        };
752
753        let dht_v6_peers_rx = if enable_dht {
754            if let Some(ref dht6) = dht_v6_initial {
755                match dht6.get_peers(magnet.info_hash()).await {
756                    Ok(rx) => Some(rx),
757                    Err(e) => {
758                        debug!("failed to start DHT v6 get_peers: {e}");
759                        None
760                    }
761                }
762            } else {
763                None
764            }
765        } else {
766            None
767        };
768
769        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
770        let download_bucket = Arc::new(parking_lot::Mutex::new(
771            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
772        ));
773        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
774            0,
775            0,
776            0,
777            0,
778            config.upload_rate_limit,
779            config.download_rate_limit,
780        );
781
782        let super_seed = if config.super_seeding {
783            Some(crate::super_seed::SuperSeedState::new())
784        } else {
785            None
786        };
787        // M118: broadcast channel — start with min capacity for magnet (resized on metadata)
788        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
789        let is_share_mode = config.share_mode;
790        let magnet_selected_files = magnet.selected_files.clone();
791        let info_hashes = magnet.info_hashes.clone();
792
793        let (piece_ready_tx, _) = broadcast::channel(64);
794        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(0));
795        let stream_read_semaphore =
796            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
797
798        let choker = Choker::with_algorithms(
799            initial_unchoke_slots(config.max_uploads_per_torrent),
800            config.seed_choking_algorithm,
801            config.choking_algorithm,
802            config.upload_rate_limit,
803            2,
804            20,
805        );
806
807        let (order_map_tx, _order_map_rx_seed) =
808            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
809
810        let actor = TorrentActor {
811            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
812                config.lock_warn_threshold_ms,
813            ),
814            config,
815            info_hash: magnet.info_hash(),
816            our_peer_id,
817            state: TorrentState::FetchingMetadata,
818            disk: None,
819            disk_manager,
820            chunk_tracker: None,
821            lengths: None,
822            num_pieces: 0,
823            streaming_pieces: BTreeSet::new(),
824            time_critical_pieces: BTreeSet::new(),
825            streaming_cursors: Vec::new(),
826            piece_ready_tx,
827            have_watch_tx,
828            have_watch_rx,
829            stream_read_semaphore,
830            file_priorities: Vec::new(),
831            wanted_pieces: Bitfield::new(0),
832            end_game: EndGame::new(),
833            peers: HashMap::new(),
834            unchoke_durations: HashMap::new(),
835            cached_peer_rates: FxHashMap::default(),
836            refill_notify: Arc::new(tokio::sync::Notify::new()),
837            atomic_states: None,
838            block_maps: None,
839            steal_candidates: None,
840            last_steal_populate: Instant::now(),
841            piece_write_guards: None,
842            soft_reap_buf: Vec::new(),
843            eviction_history: std::collections::VecDeque::new(),
844            force_immediate_choker_tick: false,
845            piece_tracker: None,
846            order_map_dirty: false,
847            next_order_map_gen: 0,
848            order_map_tx,
849            piece_owner: Vec::new(),
850            peer_slab: crate::piece_reservation::PeerSlab::new(),
851            priority_pieces: BTreeSet::new(),
852            max_in_flight: 512,
853            reservation_notify: None,
854            last_tick_dispatch_state: None,
855            choker,
856            user_seed_mode: false,
857            user_forced: false,
858            max_connections: 0,
859            peer_states: None,
860            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
861            connect_permits: HashMap::new(),
862            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
863                std::collections::HashMap::new(),
864            )),
865            connect_rx: None,
866            metadata_downloader: Some(MetadataDownloader::new(magnet.info_hash())),
867            downloaded: 0,
868            uploaded: 0,
869            checking_progress: 0.0,
870            total_download: 0,
871            total_upload: 0,
872            total_failed_bytes: 0,
873            total_redundant_bytes: 0,
874            added_time: std::time::SystemTime::now()
875                .duration_since(std::time::UNIX_EPOCH)
876                .map_or(0, |d| d.as_secs() as i64),
877            completed_time: 0,
878            last_download: 0,
879            last_upload: 0,
880            last_seen_complete: 0,
881            active_duration: 0,
882            finished_duration: 0,
883            seeding_duration: 0,
884            active_since: Some(std::time::Instant::now()),
885            state_duration_since: None,
886            started_at: std::time::Instant::now(),
887            moving_storage: false,
888            has_incoming: false,
889            need_save_resume: false,
890            error: String::new(),
891            error_file: -1,
892            cmd_rx,
893            event_tx,
894            event_rx,
895            write_error_rx,
896            write_error_tx,
897            verify_result_rx,
898            verify_result_tx,
899            pending_verify: HashSet::new(),
900            piece_generations: Vec::new(),
901            hash_result_rx,
902            hash_result_tx,
903            meta: None,
904            cached_files: None,
905            listener,
906            utp_socket,
907            utp_socket_v6,
908            tracker_manager,
909            tracker_result_rx: None,
910            dht_rx,
911            dht_v6_rx,
912            dht_enabled: enable_dht,
913            dht_peers_rx,
914            dht_v6_peers_rx,
915            dht_v6_empty_count: 0,
916            dht_v6_last_retry: None,
917            alert_tx,
918            alert_mask,
919            upload_bucket,
920            download_bucket,
921            global_upload_bucket,
922            global_download_bucket,
923            slot_tuner,
924            upload_bytes_interval: 0,
925            peak_download_rate: 0,
926            rechoke_per_min_est: 0.0,
927            web_seeds: HashMap::new(),
928            banned_web_seeds: HashSet::new(),
929            web_seed_in_flight: HashMap::new(),
930            web_seed_stats: HashMap::new(),
931            pex_peer_count: 0,
932            lsd_peer_count: 0,
933            super_seed,
934            have_broadcast_tx,
935            suggested_to_peers: HashMap::new(),
936            predictive_have_sent: HashSet::new(),
937
938            ban_manager,
939            ip_filter,
940            piece_contributors: HashMap::new(),
941            parole_pieces: HashMap::new(),
942            external_ip: None,
943            share_lru: std::collections::VecDeque::new(),
944            share_max_pieces: if is_share_mode { 64 } else { 0 },
945            plugins,
946            hash_picker: None,
947            version: irontide_core::TorrentVersion::V1Only,
948            meta_v2: None,
949            info_hashes,
950            dht_v2_peers_rx: None,
951            dht_v6_v2_peers_rx: None,
952            magnet_selected_files,
953            sam_session,
954            i2p_accept_rx: None,
955            i2p_peer_counter: 0,
956            i2p_destinations: HashMap::new(),
957            ssl_manager,
958            rate_limiter_set,
959            auto_sequential_active: false,
960            factory,
961            hash_pool_ref: hash_pool,
962            connect_attempts: 0,
963            connect_failures: 0,
964            choke_rotations: 0,
965            inflight_started: Vec::new(),
966            completed_piece_times: std::collections::VecDeque::new(),
967            piece_steals: 0,
968            holepunch_relayed: 0,
969            holepunch_relay_rate: HashMap::new(),
970            holepunch_cooldowns: HashMap::new(),
971            holepunch_pending: Vec::new(),
972            counters,
973        };
974
975        let spawn_info_hash = actor.info_hash;
976        let join_handle = tokio::spawn(actor.run());
977        tokio::spawn(async move {
978            match join_handle.await {
979                Ok(()) => {
980                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
981                }
982                Err(e) if e.is_panic() => {
983                    let panic_payload = e.into_panic();
984                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
985                        (*s).to_string()
986                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
987                        s.clone()
988                    } else {
989                        "unknown panic payload".to_string()
990                    };
991                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
992                }
993                Err(e) => {
994                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
995                }
996            }
997        });
998        Ok(Self { cmd_tx })
999    }
1000
1001    /// Send an incoming peer (routed by the session) to this torrent.
1002    ///
1003    /// # Errors
1004    /// Returns an error if the torrent actor has shut down.
1005    pub async fn send_incoming_peer(
1006        &self,
1007        stream: crate::transport::BoxedStream,
1008        addr: SocketAddr,
1009    ) -> crate::Result<()> {
1010        self.cmd_tx
1011            .send(TorrentCommand::IncomingPeer { stream, addr })
1012            .await
1013            .map_err(|_| crate::Error::Shutdown)
1014    }
1015
1016    /// Query current torrent statistics.
1017    ///
1018    /// # Errors
1019    ///
1020    /// Returns an error if the session is shut down.
1021    pub async fn stats(&self) -> crate::Result<TorrentStats> {
1022        let (tx, rx) = oneshot::channel();
1023        self.cmd_tx
1024            .send(TorrentCommand::Stats { reply: tx })
1025            .await
1026            .map_err(|_| crate::Error::Shutdown)?;
1027        rx.await.map_err(|_| crate::Error::Shutdown)
1028    }
1029
1030    /// v0.173.1: fetch the torrent's current metadata from the `TorrentActor`.
1031    ///
1032    /// Returns `Ok(None)` if metadata has not yet been assembled (magnet
1033    /// pre-resolution), `Ok(Some(meta))` once the info dict is known. Returns
1034    /// `Err(Shutdown)` if the actor has already exited. Callers that need the
1035    /// meta to exist should fall back to `crate::Error::MetadataNotReady` on
1036    /// the `Ok(None)` branch.
1037    ///
1038    /// This replaces `SessionActor.TorrentEntry.meta` as the single source of
1039    /// truth: see `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`
1040    /// for the Class A archaeology.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns an error if the session is shut down.
1045    pub async fn get_meta(&self) -> crate::Result<Option<TorrentMetaV1>> {
1046        let (tx, rx) = oneshot::channel();
1047        self.cmd_tx
1048            .send(TorrentCommand::GetMeta { reply: tx })
1049            .await
1050            .map_err(|_| crate::Error::Shutdown)?;
1051        rx.await.map_err(|_| crate::Error::Shutdown)
1052    }
1053
1054    /// Add peer addresses to the available-peer pool.
1055    ///
1056    /// # Errors
1057    ///
1058    /// Returns an error if the session is shut down.
1059    pub async fn add_peers(&self, peers: Vec<SocketAddr>, source: PeerSource) -> crate::Result<()> {
1060        self.cmd_tx
1061            .send(TorrentCommand::AddPeers { peers, source })
1062            .await
1063            .map_err(|_| crate::Error::Shutdown)
1064    }
1065
1066    /// Pause the torrent session (disconnect peers, announce Stopped).
1067    ///
1068    /// # Errors
1069    ///
1070    /// Returns an error if the session is shut down.
1071    pub async fn pause(&self) -> crate::Result<()> {
1072        self.cmd_tx
1073            .send(TorrentCommand::Pause)
1074            .await
1075            .map_err(|_| crate::Error::Shutdown)
1076    }
1077
1078    /// Queue a torrent via auto-manage (system-managed pause).
1079    ///
1080    /// # Errors
1081    ///
1082    /// Returns an error if the session is shut down.
1083    pub async fn queue(&self) -> crate::Result<()> {
1084        self.cmd_tx
1085            .send(TorrentCommand::Queue)
1086            .await
1087            .map_err(|_| crate::Error::Shutdown)
1088    }
1089
1090    /// M170: update the qBt-compat category label on this torrent.
1091    ///
1092    /// Pass `None` to clear the label. The change is visible via the
1093    /// next `stats()` call and persists across `save_resume_data`.
1094    ///
1095    /// # Errors
1096    ///
1097    /// Returns an error if the session is shut down.
1098    pub async fn set_category(&self, category: Option<String>) -> crate::Result<()> {
1099        let (tx, rx) = oneshot::channel();
1100        self.cmd_tx
1101            .send(TorrentCommand::SetCategory {
1102                category,
1103                reply: tx,
1104            })
1105            .await
1106            .map_err(|_| crate::Error::Shutdown)?;
1107        rx.await.map_err(|_| crate::Error::Shutdown)
1108    }
1109
1110    /// M171: replace this torrent's tag set wholesale (qBt-compat).
1111    ///
1112    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
1113    /// layer — always a wholesale replacement at the engine layer. The
1114    /// change is visible via the next `stats()` call and persists
1115    /// across `save_resume_data`.
1116    ///
1117    /// # Errors
1118    ///
1119    /// Returns an error if the session is shut down.
1120    pub async fn set_tags(&self, tags: Vec<String>) -> crate::Result<()> {
1121        let (tx, rx) = oneshot::channel();
1122        self.cmd_tx
1123            .send(TorrentCommand::SetTags { tags, reply: tx })
1124            .await
1125            .map_err(|_| crate::Error::Shutdown)?;
1126        rx.await.map_err(|_| crate::Error::Shutdown)
1127    }
1128
1129    /// Resume a paused torrent session (reconnect, announce Started).
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if the session is shut down.
1134    pub async fn resume(&self) -> crate::Result<()> {
1135        self.cmd_tx
1136            .send(TorrentCommand::Resume)
1137            .await
1138            .map_err(|_| crate::Error::Shutdown)
1139    }
1140
1141    /// Gracefully shut down the torrent session.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Returns an error if the session is shut down.
1146    pub async fn shutdown(&self) -> crate::Result<()> {
1147        // Best-effort send with timeout — if the channel is full or closed,
1148        // the actor will exit when all senders are dropped anyway.
1149        let _ = tokio::time::timeout(
1150            std::time::Duration::from_secs(5),
1151            self.cmd_tx.send(TorrentCommand::Shutdown),
1152        )
1153        .await;
1154        Ok(())
1155    }
1156
1157    /// Snapshot current torrent state into libtorrent-compatible resume data.
1158    ///
1159    /// # Errors
1160    ///
1161    /// Returns an error if the I/O operation fails.
1162    pub async fn save_resume_data(&self) -> crate::Result<irontide_core::FastResumeData> {
1163        let (tx, rx) = oneshot::channel();
1164        self.cmd_tx
1165            .send(TorrentCommand::SaveResumeData { reply: tx })
1166            .await
1167            .map_err(|_| crate::Error::Shutdown)?;
1168        rx.await.map_err(|_| crate::Error::Shutdown)?
1169    }
1170
1171    /// Clear the `need_save_resume` dirty flag after a successful file save.
1172    ///
1173    /// # Errors
1174    /// Returns an error if the torrent actor has shut down.
1175    pub async fn clear_save_resume_flag(&self) -> crate::Result<()> {
1176        self.cmd_tx
1177            .send(TorrentCommand::ClearSaveResumeFlag)
1178            .await
1179            .map_err(|_| crate::Error::Shutdown)
1180    }
1181
1182    /// M245 F1 — atomically take resume data IFF the torrent is dirty.
1183    ///
1184    /// Replaces the racy `stats()` → `save_resume_data()` → `clear_save_resume_flag()`
1185    /// three-step the session's periodic saver used to run per torrent. The
1186    /// actor reads `need_save_resume`, builds the resume data, and clears the
1187    /// flag in ONE indivisible command turn (no `.await` between read and
1188    /// clear), so a dirty mark set concurrently can never be lost to a clear
1189    /// that races the build.
1190    ///
1191    /// Returns `Ok(None)` when the torrent is clean (nothing to write),
1192    /// `Ok(Some(data))` when dirty data was taken and the flag cleared. On a
1193    /// build error the flag is left set so the torrent is retried next cycle.
1194    ///
1195    /// # Errors
1196    /// Returns an error if the torrent actor has shut down, or if building the
1197    /// resume data fails.
1198    pub async fn take_resume_if_dirty(
1199        &self,
1200    ) -> crate::Result<Option<irontide_core::FastResumeData>> {
1201        let (tx, rx) = oneshot::channel();
1202        self.cmd_tx
1203            .send(TorrentCommand::TakeResumeIfDirty { reply: tx })
1204            .await
1205            .map_err(|_| crate::Error::Shutdown)?;
1206        rx.await.map_err(|_| crate::Error::Shutdown)?
1207    }
1208
1209    /// M245 F1 — re-arm `need_save_resume` after a failed resume WRITE.
1210    ///
1211    /// The session saver calls this when the disk write fails AFTER
1212    /// [`Self::take_resume_if_dirty`] already cleared the flag, so the torrent
1213    /// is re-marked dirty and retried on the next save cycle instead of being
1214    /// silently dropped. Fire-and-forget.
1215    ///
1216    /// # Errors
1217    /// Returns an error if the torrent actor has shut down.
1218    pub async fn mark_resume_dirty(&self) -> crate::Result<()> {
1219        self.cmd_tx
1220            .send(TorrentCommand::MarkResumeDirty)
1221            .await
1222            .map_err(|_| crate::Error::Shutdown)
1223    }
1224
1225    /// Restore a piece bitmap from resume data (M161 Phase 4).
1226    ///
1227    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
1228    /// Returns an error if the bitfield length does not match the torrent's
1229    /// piece count or if the chunk tracker is not yet initialized.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1234    /// Returns [`crate::Error::InvalidSettings`] if the bitfield is invalid.
1235    pub async fn restore_resume_bitmap(&self, pieces: Vec<u8>) -> crate::Result<()> {
1236        let (tx, rx) = oneshot::channel();
1237        self.cmd_tx
1238            .send(TorrentCommand::RestoreResumeBitmap { pieces, reply: tx })
1239            .await
1240            .map_err(|_| crate::Error::Shutdown)?;
1241        rx.await.map_err(|_| crate::Error::Shutdown)?
1242    }
1243
1244    /// M178: Restore per-URL web-seed stats from resume data.
1245    ///
1246    /// # Errors
1247    ///
1248    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1249    pub async fn restore_web_seed_stats(
1250        &self,
1251        stats: HashMap<String, irontide_core::WebSeedStats>,
1252    ) -> crate::Result<()> {
1253        let (tx, rx) = oneshot::channel();
1254        self.cmd_tx
1255            .send(TorrentCommand::RestoreWebSeedStats { stats, reply: tx })
1256            .await
1257            .map_err(|_| crate::Error::Shutdown)?;
1258        rx.await.map_err(|_| crate::Error::Shutdown)?
1259    }
1260
1261    /// M178 Lane B3: cumulative `(pex_peer_count, lsd_peer_count)` for
1262    /// this torrent. Both counters track UNIQUE peers — duplicate
1263    /// announcements from the same peer count once.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1268    pub async fn peer_source_counts(&self) -> crate::Result<(usize, usize)> {
1269        let (tx, rx) = oneshot::channel();
1270        self.cmd_tx
1271            .send(TorrentCommand::GetPeerSourceCounts { reply: tx })
1272            .await
1273            .map_err(|_| crate::Error::Shutdown)?;
1274        rx.await.map_err(|_| crate::Error::Shutdown)
1275    }
1276
1277    /// Snapshot the per-peer cumulative unchoke duration for this torrent.
1278    ///
1279    /// # Errors
1280    ///
1281    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1282    pub async fn query_unchoke_durations(
1283        &self,
1284    ) -> crate::Result<HashMap<SocketAddr, std::time::Duration>> {
1285        let (tx, rx) = oneshot::channel();
1286        self.cmd_tx
1287            .send(TorrentCommand::QueryUnchokeDurations { reply: tx })
1288            .await
1289            .map_err(|_| crate::Error::Shutdown)?;
1290        rx.await.map_err(|_| crate::Error::Shutdown)
1291    }
1292
1293    /// M178 Lane C: snapshot per-URL `WebSeedStats` for this torrent.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns [`crate::Error::Shutdown`] if the actor has stopped.
1298    pub async fn get_web_seed_stats(&self) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
1299        let (tx, rx) = oneshot::channel();
1300        self.cmd_tx
1301            .send(TorrentCommand::GetWebSeedStats { reply: tx })
1302            .await
1303            .map_err(|_| crate::Error::Shutdown)?;
1304        rx.await.map_err(|_| crate::Error::Shutdown)
1305    }
1306
1307    /// Set the download priority for a specific file.
1308    ///
1309    /// # Errors
1310    ///
1311    /// Returns an error if the session is shut down.
1312    pub async fn set_file_priority(
1313        &self,
1314        index: usize,
1315        priority: irontide_core::FilePriority,
1316    ) -> crate::Result<()> {
1317        let (tx, rx) = oneshot::channel();
1318        self.cmd_tx
1319            .send(TorrentCommand::SetFilePriority {
1320                index,
1321                priority,
1322                reply: tx,
1323            })
1324            .await
1325            .map_err(|_| crate::Error::Shutdown)?;
1326        rx.await.map_err(|_| crate::Error::Shutdown)?
1327    }
1328
1329    /// Get the current per-file priorities.
1330    ///
1331    /// # Errors
1332    ///
1333    /// Returns an error if the session is shut down.
1334    pub async fn file_priorities(&self) -> crate::Result<Vec<irontide_core::FilePriority>> {
1335        let (tx, rx) = oneshot::channel();
1336        self.cmd_tx
1337            .send(TorrentCommand::FilePriorities { reply: tx })
1338            .await
1339            .map_err(|_| crate::Error::Shutdown)?;
1340        rx.await.map_err(|_| crate::Error::Shutdown)
1341    }
1342
1343    /// Get the list of all configured trackers with their status.
1344    ///
1345    /// # Errors
1346    ///
1347    /// Returns an error if the session is shut down.
1348    pub async fn tracker_list(&self) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
1349        let (tx, rx) = oneshot::channel();
1350        self.cmd_tx
1351            .send(TorrentCommand::TrackerList { reply: tx })
1352            .await
1353            .map_err(|_| crate::Error::Shutdown)?;
1354        rx.await.map_err(|_| crate::Error::Shutdown)
1355    }
1356
1357    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged).
1358    ///
1359    /// # Errors
1360    ///
1361    /// Returns an error if the session is shut down.
1362    pub async fn get_web_seeds(&self) -> crate::Result<Vec<String>> {
1363        let (tx, rx) = oneshot::channel();
1364        self.cmd_tx
1365            .send(TorrentCommand::GetWebSeeds { reply: tx })
1366            .await
1367            .map_err(|_| crate::Error::Shutdown)?;
1368        rx.await.map_err(|_| crate::Error::Shutdown)
1369    }
1370
1371    /// M171 Lane B: snapshot the per-piece qBt state codes.
1372    ///
1373    /// # Errors
1374    ///
1375    /// Returns an error if the session is shut down.
1376    pub async fn get_piece_states(&self) -> crate::Result<Vec<u8>> {
1377        let (tx, rx) = oneshot::channel();
1378        self.cmd_tx
1379            .send(TorrentCommand::GetPieceStates { reply: tx })
1380            .await
1381            .map_err(|_| crate::Error::Shutdown)?;
1382        rx.await.map_err(|_| crate::Error::Shutdown)
1383    }
1384
1385    /// M171 Lane B: paginated piece hash list.
1386    ///
1387    /// `offset` and `limit` are clamped to the real hash count inside
1388    /// the actor — callers can pass arbitrary values without overflow
1389    /// concerns.
1390    ///
1391    /// # Errors
1392    ///
1393    /// Returns an error if the session is shut down.
1394    pub async fn get_piece_hashes(&self, offset: u32, limit: u32) -> crate::Result<Vec<String>> {
1395        let (tx, rx) = oneshot::channel();
1396        self.cmd_tx
1397            .send(TorrentCommand::GetPieceHashes {
1398                offset,
1399                limit,
1400                reply: tx,
1401            })
1402            .await
1403            .map_err(|_| crate::Error::Shutdown)?;
1404        // M245 L3: the actor returns the raw windowed hash bytes; hex-encode
1405        // here, OFF the recv loop. Output stays byte-identical to the pre-M245
1406        // `Vec<String>` (40-char SHA-1 / 64-char SHA-256 hex).
1407        let raw = rx.await.map_err(|_| crate::Error::Shutdown)?;
1408        Ok(raw.iter().map(hex::encode).collect())
1409    }
1410
1411    /// Force all trackers to re-announce immediately.
1412    ///
1413    /// # Errors
1414    ///
1415    /// Returns an error if the session is shut down.
1416    pub async fn force_reannounce(&self) -> crate::Result<()> {
1417        self.cmd_tx
1418            .send(TorrentCommand::ForceReannounce)
1419            .await
1420            .map_err(|_| crate::Error::Shutdown)
1421    }
1422
1423    /// Scrape trackers for seeder/leecher counts.
1424    ///
1425    /// # Errors
1426    ///
1427    /// Returns an error if the session is shut down.
1428    pub async fn scrape(&self) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
1429        let (tx, rx) = oneshot::channel();
1430        self.cmd_tx
1431            .send(TorrentCommand::Scrape { reply: tx })
1432            .await
1433            .map_err(|_| crate::Error::Shutdown)?;
1434        rx.await.map_err(|_| crate::Error::Shutdown)
1435    }
1436
1437    /// Open a streaming reader for a file within the torrent.
1438    ///
1439    /// # Errors
1440    ///
1441    /// Returns an error if the session is shut down.
1442    pub async fn open_file(
1443        &self,
1444        file_index: usize,
1445    ) -> crate::Result<crate::streaming::FileStream> {
1446        let (tx, rx) = oneshot::channel();
1447        self.cmd_tx
1448            .send(TorrentCommand::OpenFile {
1449                file_index,
1450                reply: tx,
1451            })
1452            .await
1453            .map_err(|_| crate::Error::Shutdown)?;
1454        let handle = rx.await.map_err(|_| crate::Error::Shutdown)??;
1455        Ok(crate::streaming::FileStream::from_handle(handle))
1456    }
1457
1458    /// Update the external IP for BEP 40 peer priority sorting.
1459    ///
1460    /// # Errors
1461    /// Returns an error if the torrent actor has shut down.
1462    pub async fn update_external_ip(&self, ip: std::net::IpAddr) -> crate::Result<()> {
1463        self.cmd_tx
1464            .send(TorrentCommand::UpdateExternalIp { ip })
1465            .await
1466            .map_err(|_| crate::Error::Shutdown)
1467    }
1468
1469    /// Move torrent data files to a new download directory.
1470    ///
1471    /// Relocates existing files (rename or copy+delete), re-registers storage
1472    /// with the disk manager, and fires a `StorageMoved` alert on success.
1473    ///
1474    /// # Errors
1475    ///
1476    /// Returns an error if the session is shut down.
1477    pub async fn move_storage(&self, new_path: std::path::PathBuf) -> crate::Result<()> {
1478        let (tx, rx) = oneshot::channel();
1479        self.cmd_tx
1480            .send(TorrentCommand::MoveStorage {
1481                new_path,
1482                reply: tx,
1483            })
1484            .await
1485            .map_err(|_| crate::Error::Shutdown)?;
1486        rx.await.map_err(|_| crate::Error::Shutdown)?
1487    }
1488
1489    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
1490    ///
1491    /// # Errors
1492    ///
1493    /// Returns an error if the data cannot be parsed or I/O fails.
1494    pub async fn set_download_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1495        let (tx, rx) = oneshot::channel();
1496        self.cmd_tx
1497            .send(TorrentCommand::SetDownloadLimit {
1498                bytes_per_sec,
1499                reply: tx,
1500            })
1501            .await
1502            .map_err(|_| crate::Error::Shutdown)?;
1503        rx.await.map_err(|_| crate::Error::Shutdown)
1504    }
1505
1506    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
1507    ///
1508    /// # Errors
1509    ///
1510    /// Returns an error if the data cannot be parsed or I/O fails.
1511    pub async fn set_upload_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1512        let (tx, rx) = oneshot::channel();
1513        self.cmd_tx
1514            .send(TorrentCommand::SetUploadLimit {
1515                bytes_per_sec,
1516                reply: tx,
1517            })
1518            .await
1519            .map_err(|_| crate::Error::Shutdown)?;
1520        rx.await.map_err(|_| crate::Error::Shutdown)
1521    }
1522
1523    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
1524    ///
1525    /// # Errors
1526    ///
1527    /// Returns an error if the data cannot be parsed or I/O fails.
1528    pub async fn download_limit(&self) -> crate::Result<u64> {
1529        let (tx, rx) = oneshot::channel();
1530        self.cmd_tx
1531            .send(TorrentCommand::DownloadLimit { reply: tx })
1532            .await
1533            .map_err(|_| crate::Error::Shutdown)?;
1534        rx.await.map_err(|_| crate::Error::Shutdown)
1535    }
1536
1537    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
1538    ///
1539    /// # Errors
1540    ///
1541    /// Returns an error if the data cannot be parsed or I/O fails.
1542    pub async fn upload_limit(&self) -> crate::Result<u64> {
1543        let (tx, rx) = oneshot::channel();
1544        self.cmd_tx
1545            .send(TorrentCommand::UploadLimit { reply: tx })
1546            .await
1547            .map_err(|_| crate::Error::Shutdown)?;
1548        rx.await.map_err(|_| crate::Error::Shutdown)
1549    }
1550
1551    /// Enable or disable sequential (in-order) piece downloading.
1552    ///
1553    /// # Errors
1554    ///
1555    /// Returns an error if the data cannot be parsed or I/O fails.
1556    pub async fn set_sequential_download(&self, enabled: bool) -> crate::Result<()> {
1557        let (tx, rx) = oneshot::channel();
1558        self.cmd_tx
1559            .send(TorrentCommand::SetSequentialDownload { enabled, reply: tx })
1560            .await
1561            .map_err(|_| crate::Error::Shutdown)?;
1562        rx.await.map_err(|_| crate::Error::Shutdown)
1563    }
1564
1565    /// Query whether sequential downloading is enabled.
1566    ///
1567    /// # Errors
1568    ///
1569    /// Returns an error if the data cannot be parsed or I/O fails.
1570    pub async fn is_sequential_download(&self) -> crate::Result<bool> {
1571        let (tx, rx) = oneshot::channel();
1572        self.cmd_tx
1573            .send(TorrentCommand::IsSequentialDownload { reply: tx })
1574            .await
1575            .map_err(|_| crate::Error::Shutdown)?;
1576        rx.await.map_err(|_| crate::Error::Shutdown)
1577    }
1578
1579    /// M253/ER2: enable or disable first/last-pieces-first piece ordering.
1580    ///
1581    /// # Errors
1582    ///
1583    /// Returns an error if the torrent actor has shut down.
1584    pub async fn set_prioritize_first_last_pieces(&self, enabled: bool) -> crate::Result<()> {
1585        let (tx, rx) = oneshot::channel();
1586        self.cmd_tx
1587            .send(TorrentCommand::SetPrioritizeFirstLastPieces { enabled, reply: tx })
1588            .await
1589            .map_err(|_| crate::Error::Shutdown)?;
1590        rx.await.map_err(|_| crate::Error::Shutdown)
1591    }
1592
1593    /// M253/ER2: query whether first/last-pieces-first ordering is enabled.
1594    ///
1595    /// # Errors
1596    ///
1597    /// Returns an error if the torrent actor has shut down.
1598    pub async fn is_prioritize_first_last_pieces(&self) -> crate::Result<bool> {
1599        let (tx, rx) = oneshot::channel();
1600        self.cmd_tx
1601            .send(TorrentCommand::IsPrioritizeFirstLastPieces { reply: tx })
1602            .await
1603            .map_err(|_| crate::Error::Shutdown)?;
1604        rx.await.map_err(|_| crate::Error::Shutdown)
1605    }
1606
1607    /// Enable or disable BEP 16 super seeding mode.
1608    ///
1609    /// # Errors
1610    ///
1611    /// Returns an error if the session is shut down.
1612    pub async fn set_super_seeding(&self, enabled: bool) -> crate::Result<()> {
1613        let (tx, rx) = oneshot::channel();
1614        self.cmd_tx
1615            .send(TorrentCommand::SetSuperSeeding { enabled, reply: tx })
1616            .await
1617            .map_err(|_| crate::Error::Shutdown)?;
1618        rx.await.map_err(|_| crate::Error::Shutdown)
1619    }
1620
1621    /// Query whether BEP 16 super seeding mode is enabled.
1622    ///
1623    /// # Errors
1624    ///
1625    /// Returns an error if the session is shut down.
1626    pub async fn is_super_seeding(&self) -> crate::Result<bool> {
1627        let (tx, rx) = oneshot::channel();
1628        self.cmd_tx
1629            .send(TorrentCommand::IsSuperSeeding { reply: tx })
1630            .await
1631            .map_err(|_| crate::Error::Shutdown)?;
1632        rx.await.map_err(|_| crate::Error::Shutdown)
1633    }
1634
1635    /// Enable or disable user-requested seed-only mode (M159).
1636    ///
1637    /// When `enabled` is `true`, the actor stops scheduling new block requests
1638    /// and cancels all in-flight requests, but keeps existing peers connected
1639    /// and continues serving uploads. Toggling back to `false` restores normal
1640    /// piece scheduling.
1641    ///
1642    /// # Errors
1643    ///
1644    /// Returns [`crate::Error::Shutdown`] if the torrent actor has terminated.
1645    pub async fn set_seed_mode(&self, enabled: bool) -> crate::Result<()> {
1646        let (tx, rx) = oneshot::channel();
1647        self.cmd_tx
1648            .send(TorrentCommand::SetSeedMode { enabled, reply: tx })
1649            .await
1650            .map_err(|_| crate::Error::Shutdown)?;
1651        rx.await.map_err(|_| crate::Error::Shutdown)
1652    }
1653
1654    /// Add a new tracker URL to this torrent (fire-and-forget).
1655    ///
1656    /// The URL is validated and deduplicated by the tracker manager.
1657    ///
1658    /// # Errors
1659    ///
1660    /// Returns an error if the session is shut down.
1661    pub async fn add_tracker(&self, url: String) -> crate::Result<()> {
1662        self.cmd_tx
1663            .send(TorrentCommand::AddTracker { url })
1664            .await
1665            .map_err(|_| crate::Error::Shutdown)
1666    }
1667
1668    /// Replace all tracker URLs for this torrent.
1669    ///
1670    /// # Errors
1671    ///
1672    /// Returns an error if the session is shut down.
1673    pub async fn replace_trackers(&self, urls: Vec<String>) -> crate::Result<()> {
1674        let (tx, rx) = oneshot::channel();
1675        self.cmd_tx
1676            .send(TorrentCommand::ReplaceTrackers { urls, reply: tx })
1677            .await
1678            .map_err(|_| crate::Error::Shutdown)?;
1679        rx.await.map_err(|_| crate::Error::Shutdown)
1680    }
1681
1682    /// Trigger a full piece verification (force recheck).
1683    ///
1684    /// Transitions the torrent through `Checking` state, clears all piece
1685    /// completion data, re-verifies every piece against its hash, then
1686    /// transitions to `Seeding` (all valid) or `Downloading` (some missing).
1687    /// Returns after the check is complete.
1688    ///
1689    /// # Errors
1690    ///
1691    /// Returns an error if the session is shut down.
1692    pub async fn force_recheck(&self) -> crate::Result<()> {
1693        let (tx, rx) = oneshot::channel();
1694        self.cmd_tx
1695            .send(TorrentCommand::ForceRecheck { reply: tx })
1696            .await
1697            .map_err(|_| crate::Error::Shutdown)?;
1698        rx.await.map_err(|_| crate::Error::Shutdown)?
1699    }
1700
1701    /// Rename a file within the torrent on disk.
1702    ///
1703    /// Changes the filename of the specified file (by index) to `new_name`.
1704    /// The file stays in the same directory; only the filename component changes.
1705    /// Fires a `FileRenamed` alert on success.
1706    ///
1707    /// # Errors
1708    ///
1709    /// Returns an error if the session is shut down.
1710    pub async fn rename_file(&self, file_index: usize, new_name: String) -> crate::Result<()> {
1711        let (tx, rx) = oneshot::channel();
1712        self.cmd_tx
1713            .send(TorrentCommand::RenameFile {
1714                file_index,
1715                new_name,
1716                reply: tx,
1717            })
1718            .await
1719            .map_err(|_| crate::Error::Shutdown)?;
1720        rx.await.map_err(|_| crate::Error::Shutdown)?
1721    }
1722
1723    /// Route an incoming SSL peer (TLS already completed) to this torrent (M42).
1724    ///
1725    /// # Errors
1726    /// Returns an error if the torrent actor has shut down.
1727    pub async fn spawn_ssl_peer(
1728        &self,
1729        addr: SocketAddr,
1730        stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1731    ) -> crate::Result<()> {
1732        self.cmd_tx
1733            .send(TorrentCommand::SpawnSslPeer {
1734                addr,
1735                stream: crate::types::BoxedAsyncStream(Box::new(stream)),
1736            })
1737            .await
1738            .map_err(|_| crate::Error::Shutdown)
1739    }
1740
1741    /// Set the per-torrent maximum number of connections (0 = use global default).
1742    ///
1743    /// # Errors
1744    ///
1745    /// Returns an error if the connection or binding fails.
1746    pub async fn set_max_connections(&self, limit: usize) -> crate::Result<()> {
1747        let (tx, rx) = oneshot::channel();
1748        self.cmd_tx
1749            .send(TorrentCommand::SetMaxConnections { limit, reply: tx })
1750            .await
1751            .map_err(|_| crate::Error::Shutdown)?;
1752        rx.await.map_err(|_| crate::Error::Shutdown)
1753    }
1754
1755    /// Get the current per-torrent maximum connection limit (0 = use global default).
1756    ///
1757    /// # Errors
1758    ///
1759    /// Returns an error if the connection or binding fails.
1760    pub async fn max_connections(&self) -> crate::Result<usize> {
1761        let (tx, rx) = oneshot::channel();
1762        self.cmd_tx
1763            .send(TorrentCommand::MaxConnections { reply: tx })
1764            .await
1765            .map_err(|_| crate::Error::Shutdown)?;
1766        rx.await.map_err(|_| crate::Error::Shutdown)
1767    }
1768
1769    /// Set the per-torrent maximum number of upload slots (unchoke slots).
1770    ///
1771    /// # Errors
1772    ///
1773    /// Returns an error if the data cannot be parsed or I/O fails.
1774    pub async fn set_max_uploads(&self, limit: usize) -> crate::Result<()> {
1775        let (tx, rx) = oneshot::channel();
1776        self.cmd_tx
1777            .send(TorrentCommand::SetMaxUploads { limit, reply: tx })
1778            .await
1779            .map_err(|_| crate::Error::Shutdown)?;
1780        rx.await.map_err(|_| crate::Error::Shutdown)
1781    }
1782
1783    /// Get the current per-torrent maximum upload slots (unchoke slots).
1784    ///
1785    /// # Errors
1786    ///
1787    /// Returns an error if the data cannot be parsed or I/O fails.
1788    pub async fn max_uploads(&self) -> crate::Result<usize> {
1789        let (tx, rx) = oneshot::channel();
1790        self.cmd_tx
1791            .send(TorrentCommand::MaxUploads { reply: tx })
1792            .await
1793            .map_err(|_| crate::Error::Shutdown)?;
1794        rx.await.map_err(|_| crate::Error::Shutdown)
1795    }
1796
1797    /// Get per-peer details for all connected peers.
1798    ///
1799    /// # Errors
1800    ///
1801    /// Returns an error if the session is shut down.
1802    pub async fn get_peer_info(&self) -> crate::Result<Vec<PeerInfo>> {
1803        let (tx, rx) = oneshot::channel();
1804        self.cmd_tx
1805            .send(TorrentCommand::GetPeerInfo { reply: tx })
1806            .await
1807            .map_err(|_| crate::Error::Shutdown)?;
1808        rx.await.map_err(|_| crate::Error::Shutdown)
1809    }
1810
1811    /// Get in-flight piece download status (the download queue).
1812    ///
1813    /// # Errors
1814    ///
1815    /// Returns an error if the data cannot be parsed or I/O fails.
1816    pub async fn get_download_queue(&self) -> crate::Result<Vec<PartialPieceInfo>> {
1817        let (tx, rx) = oneshot::channel();
1818        self.cmd_tx
1819            .send(TorrentCommand::GetDownloadQueue { reply: tx })
1820            .await
1821            .map_err(|_| crate::Error::Shutdown)?;
1822        rx.await.map_err(|_| crate::Error::Shutdown)
1823    }
1824
1825    /// Check whether a specific piece has been downloaded.
1826    ///
1827    /// # Errors
1828    ///
1829    /// Returns an error if the session is shut down.
1830    pub async fn have_piece(&self, index: u32) -> crate::Result<bool> {
1831        let (tx, rx) = oneshot::channel();
1832        self.cmd_tx
1833            .send(TorrentCommand::HavePiece { index, reply: tx })
1834            .await
1835            .map_err(|_| crate::Error::Shutdown)?;
1836        rx.await.map_err(|_| crate::Error::Shutdown)
1837    }
1838
1839    /// Get per-piece availability counts from connected peers.
1840    ///
1841    /// # Errors
1842    ///
1843    /// Returns an error if the session is shut down.
1844    pub async fn piece_availability(&self) -> crate::Result<Vec<u32>> {
1845        let (tx, rx) = oneshot::channel();
1846        self.cmd_tx
1847            .send(TorrentCommand::PieceAvailability { reply: tx })
1848            .await
1849            .map_err(|_| crate::Error::Shutdown)?;
1850        rx.await.map_err(|_| crate::Error::Shutdown)
1851    }
1852
1853    /// Get per-file bytes-downloaded progress.
1854    ///
1855    /// # Errors
1856    ///
1857    /// Returns an error if the session is shut down.
1858    pub async fn file_progress(&self) -> crate::Result<Vec<u64>> {
1859        let (tx, rx) = oneshot::channel();
1860        self.cmd_tx
1861            .send(TorrentCommand::FileProgress { reply: tx })
1862            .await
1863            .map_err(|_| crate::Error::Shutdown)?;
1864        rx.await.map_err(|_| crate::Error::Shutdown)
1865    }
1866
1867    /// Get the torrent's identity hashes (v1 and/or v2).
1868    ///
1869    /// # Errors
1870    ///
1871    /// Returns an error if the session is shut down.
1872    pub async fn info_hashes(&self) -> crate::Result<irontide_core::InfoHashes> {
1873        let (tx, rx) = oneshot::channel();
1874        self.cmd_tx
1875            .send(TorrentCommand::InfoHashes { reply: tx })
1876            .await
1877            .map_err(|_| crate::Error::Shutdown)?;
1878        rx.await.map_err(|_| crate::Error::Shutdown)
1879    }
1880
1881    /// Get the full v1 metainfo, if available.
1882    ///
1883    /// Returns `None` for magnet links before metadata has been received.
1884    ///
1885    /// # Errors
1886    ///
1887    /// Returns an error if the session is shut down.
1888    pub async fn torrent_file(&self) -> crate::Result<Option<TorrentMetaV1>> {
1889        let (tx, rx) = oneshot::channel();
1890        self.cmd_tx
1891            .send(TorrentCommand::TorrentFile { reply: tx })
1892            .await
1893            .map_err(|_| crate::Error::Shutdown)?;
1894        rx.await.map_err(|_| crate::Error::Shutdown)
1895    }
1896
1897    /// Get the full v2 metainfo, if available.
1898    ///
1899    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
1900    /// links before metadata has been received.
1901    ///
1902    /// # Errors
1903    ///
1904    /// Returns an error if the session is shut down.
1905    pub async fn torrent_file_v2(&self) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
1906        let (tx, rx) = oneshot::channel();
1907        self.cmd_tx
1908            .send(TorrentCommand::TorrentFileV2 { reply: tx })
1909            .await
1910            .map_err(|_| crate::Error::Shutdown)?;
1911        rx.await.map_err(|_| crate::Error::Shutdown)
1912    }
1913
1914    /// Force an immediate DHT announce for this torrent.
1915    ///
1916    /// Fire-and-forget at the torrent level — the DHT announce is best-effort.
1917    ///
1918    /// # Errors
1919    ///
1920    /// Returns an error if the session is shut down.
1921    pub async fn force_dht_announce(&self) -> crate::Result<()> {
1922        self.cmd_tx
1923            .send(TorrentCommand::ForceDhtAnnounce)
1924            .await
1925            .map_err(|_| crate::Error::Shutdown)
1926    }
1927
1928    /// Read all data for a specific piece from disk.
1929    ///
1930    /// Returns the complete piece data as `Bytes`. The piece must have been
1931    /// downloaded already; use [`have_piece`](Self::have_piece) to check first.
1932    ///
1933    /// # Errors
1934    ///
1935    /// Returns an error if the data cannot be parsed or I/O fails.
1936    pub async fn read_piece(&self, index: u32) -> crate::Result<Bytes> {
1937        let (tx, rx) = oneshot::channel();
1938        self.cmd_tx
1939            .send(TorrentCommand::ReadPiece { index, reply: tx })
1940            .await
1941            .map_err(|_| crate::Error::Shutdown)?;
1942        rx.await.map_err(|_| crate::Error::Shutdown)?
1943    }
1944
1945    /// Flush the disk write cache, ensuring all buffered writes are persisted.
1946    ///
1947    /// # Errors
1948    ///
1949    /// Returns an error if the session is shut down.
1950    pub async fn flush_cache(&self) -> crate::Result<()> {
1951        let (tx, rx) = oneshot::channel();
1952        self.cmd_tx
1953            .send(TorrentCommand::FlushCache { reply: tx })
1954            .await
1955            .map_err(|_| crate::Error::Shutdown)?;
1956        rx.await.map_err(|_| crate::Error::Shutdown)?
1957    }
1958
1959    /// Check whether this handle refers to a live torrent.
1960    ///
1961    /// Returns `false` after the torrent has been removed or shut down.
1962    /// This is a synchronous check on the channel state — no command dispatch.
1963    #[must_use]
1964    pub fn is_valid(&self) -> bool {
1965        !self.cmd_tx.is_closed()
1966    }
1967
1968    /// Clear any error state on the torrent and resume if it was paused due to error.
1969    ///
1970    /// # Errors
1971    ///
1972    /// Returns an error if the session is shut down.
1973    pub async fn clear_error(&self) -> crate::Result<()> {
1974        self.cmd_tx
1975            .send(TorrentCommand::ClearError)
1976            .await
1977            .map_err(|_| crate::Error::Shutdown)
1978    }
1979
1980    /// Get per-file open/mode status based on the current torrent state.
1981    ///
1982    /// Returns one [`crate::types::FileStatus`] entry per file in the torrent.
1983    ///
1984    /// # Errors
1985    ///
1986    /// Returns an error if the session is shut down.
1987    pub async fn file_status(&self) -> crate::Result<Vec<crate::types::FileStatus>> {
1988        let (tx, rx) = oneshot::channel();
1989        self.cmd_tx
1990            .send(TorrentCommand::FileStatus { reply: tx })
1991            .await
1992            .map_err(|_| crate::Error::Shutdown)?;
1993        rx.await.map_err(|_| crate::Error::Shutdown)
1994    }
1995
1996    /// Read the current torrent state as a [`TorrentFlags`] bitflag set.
1997    ///
1998    /// # Errors
1999    ///
2000    /// Returns an error if the session is shut down.
2001    pub async fn flags(&self) -> crate::Result<crate::types::TorrentFlags> {
2002        let (tx, rx) = oneshot::channel();
2003        self.cmd_tx
2004            .send(TorrentCommand::Flags { reply: tx })
2005            .await
2006            .map_err(|_| crate::Error::Shutdown)?;
2007        rx.await.map_err(|_| crate::Error::Shutdown)
2008    }
2009
2010    /// Set (enable) the specified torrent flags.
2011    ///
2012    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
2013    ///
2014    /// # Errors
2015    ///
2016    /// Returns an error if the session is shut down.
2017    pub async fn set_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
2018        let (tx, rx) = oneshot::channel();
2019        self.cmd_tx
2020            .send(TorrentCommand::SetFlags { flags, reply: tx })
2021            .await
2022            .map_err(|_| crate::Error::Shutdown)?;
2023        rx.await.map_err(|_| crate::Error::Shutdown)
2024    }
2025
2026    /// Unset (disable) the specified torrent flags.
2027    ///
2028    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
2029    ///
2030    /// # Errors
2031    ///
2032    /// Returns an error if the session is shut down.
2033    pub async fn unset_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
2034        let (tx, rx) = oneshot::channel();
2035        self.cmd_tx
2036            .send(TorrentCommand::UnsetFlags { flags, reply: tx })
2037            .await
2038            .map_err(|_| crate::Error::Shutdown)?;
2039        rx.await.map_err(|_| crate::Error::Shutdown)
2040    }
2041
2042    /// Immediately initiate a peer connection to the given address.
2043    ///
2044    /// Bypasses the normal peer selection queue — the connection attempt starts
2045    /// right away. Fire-and-forget: no reply is sent.
2046    ///
2047    /// # Errors
2048    ///
2049    /// Returns an error if the connection or binding fails.
2050    pub async fn connect_peer(&self, addr: SocketAddr) -> crate::Result<()> {
2051        self.cmd_tx
2052            .send(TorrentCommand::ConnectPeer { addr })
2053            .await
2054            .map_err(|_| crate::Error::Shutdown)
2055    }
2056
2057    /// M147: Send pre-resolved metadata from the background resolver.
2058    ///
2059    /// Fire-and-forget: uses `try_send` to avoid blocking the resolver task.
2060    /// If the channel is full or closed, the pre-resolved metadata is silently
2061    /// discarded (the `TorrentActor`'s own `FetchingMetadata` phase will handle it).
2062    pub fn send_pre_resolved_metadata(&self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
2063        let _ = self
2064            .cmd_tx
2065            .try_send(TorrentCommand::PreResolvedMetadata { info_bytes, peers });
2066    }
2067
2068    /// **TEST-ONLY (v0.173.2).** Synchronous counterpart to
2069    /// [`Self::send_pre_resolved_metadata`] that waits for the actor to
2070    /// finish processing before returning.
2071    ///
2072    /// Uses backpressured `cmd_tx.send().await` plus a oneshot completion
2073    /// ack, so `test_inject_metadata(...).await` resolves only after the
2074    /// metadata has been processed by the `TorrentActor`. See
2075    /// `TorrentCommand::TestInjectMetadata` for the rationale.
2076    ///
2077    /// # Errors
2078    /// - [`crate::Error::Shutdown`] if the torrent command channel is closed.
2079    ///
2080    /// # Visibility
2081    /// `pub` (not `pub(crate)`) because the sole caller is `SessionActor::run`
2082    /// in `irontide-session` — a *different* crate since the M244 split moved
2083    /// `torrent.rs` here. Still gated behind `test-util`, so it only exists in
2084    /// test builds; `irontide-session/test-util` forwards to
2085    /// `irontide-engine/test-util` to keep the call site and definition in sync.
2086    #[cfg(feature = "test-util")]
2087    pub async fn test_inject_metadata(&self, info_bytes: Vec<u8>) -> crate::Result<()> {
2088        let (tx, rx) = tokio::sync::oneshot::channel();
2089        self.cmd_tx
2090            .send(TorrentCommand::TestInjectMetadata {
2091                info_bytes,
2092                reply: tx,
2093            })
2094            .await
2095            .map_err(|_| crate::Error::Shutdown)?;
2096        rx.await.map_err(|_| crate::Error::Shutdown)?;
2097        Ok(())
2098    }
2099}
2100
2101// ---------------------------------------------------------------------------
2102// M116: Cached file metadata for zero-allocation piece completion checks
2103// ---------------------------------------------------------------------------
2104
2105/// Pre-computed file metadata for zero-allocation piece completion checks.
2106#[derive(Debug, Clone)]
2107pub(crate) struct CachedFileEntry {
2108    pub(crate) index: usize,
2109    #[allow(dead_code)] // Used in tests; retained for future diagnostics
2110    pub(crate) length: u64,
2111    pub(crate) first_piece: u32,
2112    pub(crate) last_piece: u32,
2113}
2114
2115/// Cached file-to-piece mapping, computed once at torrent registration.
2116#[derive(Debug, Clone)]
2117pub(crate) struct CachedFileInfo {
2118    pub(crate) entries: Vec<CachedFileEntry>,
2119}
2120
2121pub(crate) fn build_cached_file_info(meta: &TorrentMetaV1, lengths: &Lengths) -> CachedFileInfo {
2122    let piece_length = lengths.piece_length();
2123    let files = meta.info.files();
2124    let mut entries = Vec::with_capacity(files.len());
2125    let mut offset = 0u64;
2126    for (index, file) in files.iter().enumerate() {
2127        let first_piece = (offset / piece_length) as u32;
2128        let last_piece = if file.length == 0 {
2129            first_piece
2130        } else {
2131            ((offset + file.length - 1) / piece_length) as u32
2132        };
2133        entries.push(CachedFileEntry {
2134            index,
2135            length: file.length,
2136            first_piece,
2137            last_piece,
2138        });
2139        offset += file.length;
2140    }
2141    CachedFileInfo { entries }
2142}
2143
2144// ---------------------------------------------------------------------------
2145// TorrentActor — internal single-owner event loop
2146// ---------------------------------------------------------------------------
2147
2148pub(crate) struct TorrentActor {
2149    pub(crate) config: TorrentConfig,
2150    /// M120: Lock timing settings for hot-path diagnostics.
2151    pub(crate) lock_timing: crate::timed_lock::LockTimingSettings,
2152    pub(crate) info_hash: Id20,
2153    pub(crate) our_peer_id: Id20,
2154    pub(crate) state: TorrentState,
2155
2156    // Disk I/O (None in magnet mode until metadata arrives)
2157    pub(crate) disk: Option<DiskHandle>,
2158    pub(crate) disk_manager: DiskManagerHandle,
2159    pub(crate) chunk_tracker: Option<ChunkTracker>,
2160    pub(crate) lengths: Option<Lengths>,
2161    pub(crate) num_pieces: u32,
2162
2163    // Piece management
2164    pub(crate) file_priorities: Vec<FilePriority>,
2165    pub(crate) wanted_pieces: Bitfield,
2166    pub(crate) end_game: EndGame,
2167
2168    // Streaming (M28)
2169    pub(crate) streaming_pieces: BTreeSet<u32>,
2170    pub(crate) time_critical_pieces: BTreeSet<u32>,
2171    pub(crate) streaming_cursors: Vec<crate::streaming::StreamingCursor>,
2172    pub(crate) piece_ready_tx: broadcast::Sender<u32>,
2173    pub(crate) have_watch_tx: tokio::sync::watch::Sender<Bitfield>,
2174    pub(crate) have_watch_rx: tokio::sync::watch::Receiver<Bitfield>,
2175    pub(crate) stream_read_semaphore: Arc<tokio::sync::Semaphore>,
2176
2177    // Peer management
2178    pub(crate) peers: HashMap<SocketAddr, PeerState>,
2179    /// Per-(SocketAddr × torrent) cumulative time we had each peer
2180    /// unchoked. Survives reconnects: when a `PeerState` is dropped on
2181    /// disconnect, its `unchoke_duration_total` is flushed into this map
2182    /// keyed by the peer's `SocketAddr`. Reads via
2183    /// `SessionHandle::peer_unchoke_durations` sum the persistent value
2184    /// here with each currently-live `PeerState`'s in-flight accumulator.
2185    /// Used by the libtorrent-mirror `optimistic_unchoke_fairness` perf
2186    /// scenario to assert the choker rotates upload slots fairly.
2187    pub(crate) unchoke_durations: HashMap<SocketAddr, Duration>,
2188    /// Cached peer download rates for piece stealing decisions.
2189    /// Refreshed on each periodic tick (~1s) instead of rebuilding per block.
2190    pub(crate) cached_peer_rates: FxHashMap<SocketAddr, f64>,
2191    /// Notify handle for reactive queue refill (legacy, unused in M73).
2192    #[allow(dead_code)]
2193    pub(crate) refill_notify: Arc<tokio::sync::Notify>,
2194    /// M93: Lock-free piece states (shared with peers via Arc).
2195    pub(crate) atomic_states: Option<Arc<crate::piece_reservation::AtomicPieceStates>>,
2196    /// M103: Shared block-level request/received bitmaps.
2197    pub(crate) block_maps: Option<Arc<BlockMaps>>,
2198    /// M103: Shared queue of stealable pieces.
2199    pub(crate) steal_candidates: Option<Arc<StealCandidates>>,
2200    /// M132: Last time we populated the steal queue with in-flight pieces.
2201    pub(crate) last_steal_populate: Instant,
2202    /// M120: Per-piece write guards to prevent steal/write races.
2203    pub(crate) piece_write_guards: Option<Arc<crate::piece_reservation::PieceWriteGuards>>,
2204    /// v0.173.3: Reusable buffer for `soft_reap_candidates` output.
2205    /// Reaped every `soft_reap_interval` tick (~1/s) with typical size
2206    /// 0-16 entries; held here so the allocation is reused across ticks.
2207    pub(crate) soft_reap_buf: Vec<std::net::SocketAddr>,
2208    /// v0.187.3 / 3A: sliding-window of recent proactive eviction timestamps.
2209    /// Entries older than 60s are pruned on each tick; the actor refuses to
2210    /// evict when the post-prune length is >= `proactive_evictions_per_minute_limit`.
2211    /// Prevents the 130 → 20-50 churn observed in the dogfood report.
2212    pub(crate) eviction_history: std::collections::VecDeque<std::time::Instant>,
2213    /// v0.187.3 / Bug 8a: when set, the main loop runs the choker on the
2214    /// next iteration regardless of `unchoke_interval`. Set on transition
2215    /// INTO `Seeding` so interested peers see Unchoke promptly. Cleared
2216    /// after the next `run_choker().await`.
2217    pub(crate) force_immediate_choker_tick: bool,
2218    /// M187: Actor-owned piece dispatch tracker (direct-acquire model).
2219    pub(crate) piece_tracker: Option<PieceTracker>,
2220    /// M187: Watch sender that broadcasts `PieceOrderMap` to peer tasks.
2221    pub(crate) order_map_tx: tokio::sync::watch::Sender<Arc<PieceOrderMap>>,
2222
2223    /// M246: set by the `SetFilePriority` arm to request a coalesced order-map
2224    /// rebuild on the next 1 s pipeline tick — a batch of M priority changes
2225    /// sets it M times but triggers ONE rebuild. Cleared by
2226    /// `rebuild_order_map_now`.
2227    pub(crate) order_map_dirty: bool,
2228
2229    /// M246: actor-owned monotone generation counter for the published
2230    /// `PieceOrderMap`. Every rebuild assigns `next_order_map_gen += 1`; the
2231    /// generation is NEVER derived from the published `watch` value — under any
2232    /// async publish that would let two back-to-back batch rebuilds read the
2233    /// same generation and drop one (the rejected-Candidate-H bug class).
2234    pub(crate) next_order_map_gen: u64,
2235    /// M93: Maps piece index -> peer slab slot that owns it.
2236    pub(crate) piece_owner: Vec<Option<u16>>,
2237    /// M93: Arena-allocated peer tracking: slot <-> `SocketAddr`.
2238    pub(crate) peer_slab: crate::piece_reservation::PeerSlab,
2239    #[allow(dead_code)]
2240    pub(crate) priority_pieces: BTreeSet<u32>,
2241    /// M93: Maximum in-flight pieces.
2242    pub(crate) max_in_flight: usize,
2243    /// Piece notify handle (for driver spawning).
2244    pub(crate) reservation_notify: Option<Arc<tokio::sync::Notify>>,
2245    /// Dispatch state snapshot at the previous pipeline tick:
2246    /// `(queue_pieces.count_ones(), inflight.len())`. The tick uses this to
2247    /// gate its `notify_waiters()` safety-net call — if neither value has
2248    /// changed, no peer needs waking, and waking all of them would just
2249    /// trigger spurious acquire calls (the 93% `NoneAvailable` rate measured
2250    /// on 2026-05-11 was largely driven by unconditional 1 Hz wake spam).
2251    /// `None` until the first tick records a baseline.
2252    pub(crate) last_tick_dispatch_state: Option<(u32, usize)>,
2253    pub(crate) choker: Choker,
2254    /// M159: User-requested seed-only mode flag.
2255    ///
2256    /// When `true`, the actor stops issuing new block requests (gating the
2257    /// `StartRequesting` dispatch sent to peer tasks) and cancels any
2258    /// in-flight requests. Uploads continue unaffected. Distinct from the
2259    /// naturally-complete seeding state tracked by `state == Seeding`.
2260    pub(crate) user_seed_mode: bool,
2261    /// Whether the user force-started this torrent (bypassing queue limits).
2262    pub(crate) user_forced: bool,
2263    /// Per-torrent connection limit override (0 = use `config.max_peers`).
2264    pub(crate) max_connections: usize,
2265    /// M137: Unified peer lifecycle tracker (replaces `peers_connected` + `connect_backoff` + `peer_tx` + `unique_peers_attempted`).
2266    pub(crate) peer_states: Option<Arc<crate::peer_states::PeerStates>>,
2267    /// M147: `ConnectPool` semaphore — gates connection attempts only.
2268    /// Permits are released on `HandshakeComplete` (not held for peer lifetime).
2269    pub(crate) connect_semaphore: Arc<tokio::sync::Semaphore>,
2270    /// M147: Maps peer address → permit holder. Permits are taken on `HandshakeComplete`
2271    /// to free `ConnectPool` slots. RAII cleanup on task exit handles failure cases.
2272    pub(crate) connect_permits:
2273        HashMap<SocketAddr, Arc<parking_lot::Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>>,
2274    /// M107: Receiver for connect requests from the adder task.
2275    pub(crate) connect_rx: Option<mpsc::Receiver<ConnectPeer>>,
2276
2277    // Metadata (for magnet links)
2278    pub(crate) metadata_downloader: Option<MetadataDownloader>,
2279
2280    // Parsed torrent meta (for piece hash verification)
2281    pub(crate) meta: Option<TorrentMetaV1>,
2282
2283    /// M116: Pre-computed file->piece mapping for zero-alloc completion checks.
2284    pub(crate) cached_files: Option<CachedFileInfo>,
2285
2286    // Stats
2287    pub(crate) downloaded: u64,
2288    pub(crate) uploaded: u64,
2289    pub(crate) checking_progress: f32,
2290    pub(crate) total_download: u64,
2291    pub(crate) total_upload: u64,
2292    pub(crate) total_failed_bytes: u64,
2293    pub(crate) total_redundant_bytes: u64,
2294    pub(crate) added_time: i64,
2295    pub(crate) completed_time: i64,
2296    pub(crate) last_download: i64,
2297    pub(crate) last_upload: i64,
2298    pub(crate) last_seen_complete: i64,
2299    pub(crate) active_duration: i64,
2300    pub(crate) finished_duration: i64,
2301    pub(crate) seeding_duration: i64,
2302    pub(crate) active_since: Option<std::time::Instant>,
2303    pub(crate) state_duration_since: Option<std::time::Instant>,
2304    #[allow(dead_code)] // M104: ConnectPhase removed; kept for future diagnostics
2305    pub(crate) started_at: std::time::Instant,
2306    pub(crate) moving_storage: bool,
2307    pub(crate) has_incoming: bool,
2308    pub(crate) need_save_resume: bool,
2309    pub(crate) error: String,
2310    pub(crate) error_file: i32,
2311
2312    // Channels
2313    pub(crate) cmd_rx: mpsc::Receiver<TorrentCommand>,
2314    pub(crate) event_tx: mpsc::Sender<PeerEvent>,
2315    pub(crate) event_rx: mpsc::Receiver<PeerEvent>,
2316
2317    // Async disk pipeline channels
2318    pub(crate) write_error_rx: mpsc::Receiver<crate::disk::DiskWriteError>,
2319    pub(crate) write_error_tx: mpsc::Sender<crate::disk::DiskWriteError>,
2320    pub(crate) verify_result_rx: mpsc::Receiver<crate::disk::VerifyResult>,
2321    pub(crate) verify_result_tx: mpsc::Sender<crate::disk::VerifyResult>,
2322    /// Pieces currently awaiting async verification — prevents duplicate
2323    /// verify tasks when end game or slow peers deliver duplicate blocks.
2324    pub(crate) pending_verify: HashSet<u32>,
2325    /// Generation counter per piece — increments on release/re-reserve.
2326    /// Used to detect stale hash results from the `HashPool` (M96).
2327    pub(crate) piece_generations: Vec<u64>,
2328    /// Receiver for hash pool results (M96).
2329    pub(crate) hash_result_rx: tokio::sync::mpsc::Receiver<crate::hash_pool::HashResult>,
2330    /// Sender for hash pool results — cloned into `DiskHandle` (M96).
2331    pub(crate) hash_result_tx: tokio::sync::mpsc::Sender<crate::hash_pool::HashResult>,
2332
2333    // TCP listener for incoming peer connections
2334    pub(crate) listener: Option<Box<dyn crate::transport::TransportListener>>,
2335
2336    // uTP socket for outbound connections (shared with session, cloned)
2337    pub(crate) utp_socket: Option<irontide_utp::UtpSocket>,
2338    // IPv6 uTP socket for outbound connections to IPv6 peers
2339    pub(crate) utp_socket_v6: Option<irontide_utp::UtpSocket>,
2340
2341    // Tracker management
2342    pub(crate) tracker_manager: TrackerManager,
2343    /// M143: Receiver for streaming tracker announce results.
2344    /// `Some` while a background announce is in-flight, `None` when idle.
2345    pub(crate) tracker_result_rx: Option<mpsc::Receiver<crate::tracker_manager::TrackerPeerBatch>>,
2346
2347    // DHT handles (shared, optional). M173 Lane B (B6): subscribe to
2348    // the session-level DhtBroadcast so a runtime DHT restart is
2349    // observed on the next call without holding a stale clone. The
2350    // `dht_enabled` flag gates BEP 27 private torrents (which must
2351    // not announce to DHT regardless of session-level enable) plus
2352    // any per-torrent opt-out.
2353    pub(crate) dht_rx: irontide_dht::DhtReceiver,
2354    pub(crate) dht_v6_rx: irontide_dht::DhtReceiver,
2355    pub(crate) dht_enabled: bool,
2356    pub(crate) dht_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2357    pub(crate) dht_v6_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2358    /// Consecutive times the V6 DHT returned an empty table.
2359    /// After 30 failures (~3s at 100ms), stop retrying to avoid log spam.
2360    pub(crate) dht_v6_empty_count: u32,
2361    /// Timestamp of last V6 DHT retry attempt (M97).
2362    pub(crate) dht_v6_last_retry: Option<std::time::Instant>,
2363
2364    // Alert system (M15)
2365    pub(crate) alert_tx: broadcast::Sender<Alert>,
2366    pub(crate) alert_mask: Arc<AtomicU32>,
2367
2368    // Rate limiting (M14)
2369    pub(crate) upload_bucket: crate::rate_limiter::TokenBucket,
2370    pub(crate) download_bucket: SharedBucket,
2371    pub(crate) global_upload_bucket: Option<SharedBucket>,
2372    #[allow(dead_code)] // M73: rate limiting deferred to M74
2373    pub(crate) global_download_bucket: Option<SharedBucket>,
2374    pub(crate) slot_tuner: crate::slot_tuner::SlotTuner,
2375    pub(crate) upload_bytes_interval: u64,
2376
2377    /// Peak aggregate download rate observed (bytes/sec), for peer turnover cutoff.
2378    pub(crate) peak_download_rate: u64,
2379
2380    /// M257f: torrent-wide remote choke-direction flips/min EWMA (α 0.1,
2381    /// updated each pipeline tick from `prev_choking` edges). Above
2382    /// `CHURN_SUSPEND_FLIPS_PER_MIN`, BDP depth pricing suspends — see
2383    /// the threshold doc in `request_budget`.
2384    pub(crate) rechoke_per_min_est: f64,
2385
2386    // Web seeding (M22)
2387    pub(crate) web_seeds: HashMap<String, mpsc::Sender<crate::web_seed::WebSeedCommand>>,
2388    pub(crate) banned_web_seeds: HashSet<String>,
2389    pub(crate) web_seed_in_flight: HashMap<u32, String>,
2390    /// M178: Per-URL stats accumulated from `PeerEvent::WebSeedProgress`
2391    /// events emitted by `WebSeedTask`. Persisted to fast-resume so stats
2392    /// survive app restart (see `resume_file.rs`).
2393    pub(crate) web_seed_stats: HashMap<String, irontide_core::WebSeedStats>,
2394    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2395    /// via PEX (BEP 11) since this actor started. Surfaced through
2396    /// `SessionHandle::pex_peer_count` for the qBt v2 trackers endpoint
2397    /// and the GUI Trackers tab pseudo-tracker rows. Reset on torrent
2398    /// removal (actor lifecycle).
2399    pub(crate) pex_peer_count: usize,
2400    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2401    /// via LSD (BEP 14) multicast. Self-cookie filtering happens upstream
2402    /// in M174's session-level LSD path.
2403    pub(crate) lsd_peer_count: usize,
2404
2405    // BEP 16 super seeding (M23)
2406    pub(crate) super_seed: Option<crate::super_seed::SuperSeedState>,
2407    // M118: Broadcast channel for Have distribution (replaces HaveBuffer)
2408    pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender<u32>,
2409
2410    /// M44: pieces we've suggested to each peer (avoid re-suggesting)
2411    pub(crate) suggested_to_peers: HashMap<SocketAddr, HashSet<u32>>,
2412
2413    /// M44: pieces for which we've already sent predictive Have
2414    pub(crate) predictive_have_sent: HashSet<u32>,
2415
2416    // Smart banning (M25)
2417    pub(crate) ban_manager: irontide_session_types::SharedBanManager,
2418    pub(crate) piece_contributors: HashMap<u32, HashSet<std::net::IpAddr>>,
2419    pub(crate) parole_pieces: HashMap<u32, crate::ban::ParoleState>,
2420
2421    // IP filtering (M29)
2422    pub(crate) ip_filter: irontide_session_types::SharedIpFilter,
2423
2424    // BEP 40 peer priority (M32b)
2425    pub(crate) external_ip: Option<std::net::IpAddr>,
2426
2427    // Share mode (M32c): LRU tracker for in-memory piece relay.
2428    // Tracks which pieces are currently "live" (servable) in share mode.
2429    // Oldest pieces are evicted when capacity is reached.
2430    pub(crate) share_lru: std::collections::VecDeque<u32>,
2431    /// Max pieces to keep live in share mode (0 = share mode disabled).
2432    pub(crate) share_max_pieces: usize,
2433
2434    // Extension plugins (M32d)
2435    pub(crate) plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
2436
2437    // BEP 52 v2/hybrid support (M34-M35)
2438    pub(crate) hash_picker: Option<irontide_core::HashPicker>,
2439    pub(crate) version: irontide_core::TorrentVersion,
2440    #[allow(dead_code)] // stored for hybrid torrent re-serialization (M35 Task 5)
2441    pub(crate) meta_v2: Option<irontide_core::TorrentMetaV2>,
2442
2443    /// Full info hashes for dual-swarm support (v1 + v2 for hybrid).
2444    pub(crate) info_hashes: irontide_core::InfoHashes,
2445
2446    /// Dual-swarm DHT peer receivers (v2 hash in hybrid torrents).
2447    pub(crate) dht_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2448    pub(crate) dht_v6_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2449
2450    /// BEP 53: deferred file selection from magnet `so=` parameter.
2451    /// Applied after metadata is received to set file priorities.
2452    pub(crate) magnet_selected_files: Option<Vec<irontide_core::FileSelection>>,
2453
2454    /// I2P SAM session for anonymous peer connections (M41).
2455    pub(crate) sam_session: Option<Arc<crate::i2p::SamSession>>,
2456
2457    /// Receiver for incoming I2P peer connections (M41).
2458    pub(crate) i2p_accept_rx: Option<mpsc::Receiver<crate::i2p::SamStream>>,
2459
2460    /// Counter for generating synthetic `SocketAddr` values for I2P peers (M41).
2461    pub(crate) i2p_peer_counter: u32,
2462
2463    /// Maps synthetic `SocketAddr` → `I2pDestination` for outbound I2P connects.
2464    pub(crate) i2p_destinations: HashMap<SocketAddr, crate::i2p::I2pDestination>,
2465
2466    /// SSL manager for SSL torrent certificate handling (M42).
2467    pub(crate) ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
2468
2469    /// Per-class rate limiting with mixed-mode (M45).
2470    pub(crate) rate_limiter_set: crate::rate_limiter::RateLimiterSet,
2471    /// Whether auto-sequential mode is currently active (hysteresis state).
2472    pub(crate) auto_sequential_active: bool,
2473    /// Network transport factory for TCP operations (M51).
2474    pub(crate) factory: Arc<crate::transport::NetworkFactory>,
2475    /// Shared hash pool for parallel piece verification (M96).
2476    pub(crate) hash_pool_ref: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
2477    /// M108: Shared snapshot of connected peer addresses for PEX send tasks.
2478    pub(crate) live_outgoing_peers:
2479        std::sync::Arc<parking_lot::RwLock<std::collections::HashMap<SocketAddr, u8>>>,
2480    /// M108: Total outbound connection attempts dispatched to peer adder.
2481    pub(crate) connect_attempts: u64,
2482    /// M108: Total connection failures (peers that disconnected).
2483    pub(crate) connect_failures: u64,
2484    /// M138: Total number of peers evicted by proactive choke rotation.
2485    pub(crate) choke_rotations: u64,
2486    /// M149: When each in-flight piece started downloading. Indexed by piece index.
2487    /// Set when `piece_owner` assigns a piece, cleared on verify/hash-fail.
2488    pub(crate) inflight_started: Vec<Option<Instant>>,
2489    /// M149: Rolling window of recent piece completion times for steal threshold.
2490    pub(crate) completed_piece_times: std::collections::VecDeque<Duration>,
2491    /// M149: Total number of piece-level steals performed.
2492    pub(crate) piece_steals: u64,
2493    /// M190: Total holepunch rendezvous requests we relayed.
2494    pub(crate) holepunch_relayed: u64,
2495    /// M190: Per-peer rate limit for holepunch rendezvous requests.
2496    pub(crate) holepunch_relay_rate: HashMap<SocketAddr, (Instant, u32)>,
2497    /// M112: Tracks recent holepunch attempts to prevent retry storms.
2498    pub(crate) holepunch_cooldowns: HashMap<SocketAddr, Instant>,
2499    /// M112: Buffer for holepunch attempts (`disconnect_peer` is sync, `try_holepunch` is async).
2500    pub(crate) holepunch_pending: Vec<SocketAddr>,
2501    /// Sim-perf engine surface: shared session counters used by
2502    /// `rebuild_availability_snapshot` to track Allow / Defer rates,
2503    /// and by per-peer spawn sites to seed `PeerShared::counters`.
2504    pub(crate) counters: Arc<crate::stats::SessionCounters>,
2505}
2506
2507/// Maximum number of in-flight end-game requests per peer.
2508/// libtorrent continues full pipelining in end-game; we use a moderate
2509/// depth so that round-trip latency doesn't bottleneck throughput.
2510/// End-game pipeline depth: match normal mode (128 slots per peer).
2511/// Safe because the reactive per-block cascade was replaced with a 200ms
2512/// batch refill tick — raising depth no longer amplifies picker invocations.
2513pub(crate) const END_GAME_DEPTH: usize = 128;
2514
2515/// Minimum free pipeline slots before invoking the full piece picker in
2516/// `handle_piece_data()`.  Avoids running the 5-layer picker on every single
2517impl TorrentActor {
2518    /// Returns the current IPv4 `DhtHandle`, or `None` if DHT is disabled
2519    /// for this torrent (BEP 27 private, per-torrent opt-out, or
2520    /// session-level disable). M173 Lane B (B6): reads from the
2521    /// session-level [`irontide_dht::DhtBroadcast`] receiver, so a
2522    /// runtime DHT restart (B11) is observed transparently here.
2523    pub(crate) fn current_dht(&self) -> Option<irontide_dht::DhtHandle> {
2524        if self.dht_enabled {
2525            self.dht_rx.current()
2526        } else {
2527            None
2528        }
2529    }
2530
2531    /// Returns the current IPv6 `DhtHandle`, or `None` if DHT is disabled.
2532    /// See [`Self::current_dht`].
2533    pub(crate) fn current_dht_v6(&self) -> Option<irontide_dht::DhtHandle> {
2534        if self.dht_enabled {
2535            self.dht_v6_rx.current()
2536        } else {
2537            None
2538        }
2539    }
2540
2541    /// Hold-window helper: wait up to `hold` for the IPv4 DHT broadcast
2542    /// to deliver a non-`None` handle. Returns `None` if the wait
2543    /// times out OR if DHT is disabled for this torrent.
2544    ///
2545    /// Used by call sites that issue requests during a DHT restart
2546    /// window and prefer to hold a brief moment rather than fail
2547    /// immediately. The hold is bounded — callers must not loop on
2548    /// `None`, since a permanently-disabled DHT will hit the timeout
2549    /// every iteration.
2550    ///
2551    /// # Errors
2552    ///
2553    /// Returns `None` on timeout, on disabled DHT, or if the
2554    /// broadcast sender has been dropped (session shutting down).
2555    #[allow(dead_code)] // wired by future per-call-site refactors as needed
2556    pub(crate) async fn current_dht_or_wait(
2557        &mut self,
2558        hold: std::time::Duration,
2559    ) -> Option<irontide_dht::DhtHandle> {
2560        if !self.dht_enabled {
2561            return None;
2562        }
2563        if let Some(handle) = self.dht_rx.current() {
2564            return Some(handle);
2565        }
2566        // Wait for the broadcast to fire `replace(Some(_))`.
2567        match tokio::time::timeout(hold, self.dht_rx.changed()).await {
2568            Ok(Ok(())) => self.dht_rx.current(),
2569            Ok(Err(_)) | Err(_) => None,
2570        }
2571    }
2572
2573    /// Main event loop.
2574    async fn run(mut self) {
2575        // Verify existing pieces on startup (resume support)
2576        self.verify_existing_pieces().await;
2577
2578        // M93: Initialize lock-free piece states after verification
2579        // so we_have reflects already-verified pieces.
2580        if let Some(ct) = &self.chunk_tracker {
2581            let atomic_states = Arc::new(AtomicPieceStates::new(
2582                self.num_pieces,
2583                ct.bitfield(),
2584                &self.wanted_pieces,
2585            ));
2586            self.atomic_states = Some(Arc::clone(&atomic_states));
2587            self.piece_owner = vec![None; self.num_pieces as usize];
2588            // M149: Initialize inflight tracking
2589            self.inflight_started = vec![None; self.num_pieces as usize];
2590            self.max_in_flight = self.config.max_in_flight_pieces;
2591
2592            // M103: Initialize block stealing infrastructure
2593            if self.config.use_block_stealing {
2594                if let Some(ref lengths) = self.lengths {
2595                    self.block_maps = Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
2596                }
2597                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
2598            }
2599            // M120: Per-piece write guards
2600            self.piece_write_guards = Some(Arc::new(
2601                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
2602            ));
2603
2604            // M187: Init direct-acquire dispatch state.
2605            self.piece_tracker = Some(PieceTracker::new(
2606                self.num_pieces,
2607                ct.bitfield(),
2608                &self.wanted_pieces,
2609            ));
2610            if let Some(ref cached) = self.cached_files {
2611                let file_piece_ranges: Vec<(u32, u32)> = cached
2612                    .entries
2613                    .iter()
2614                    .map(|e| (e.first_piece, e.last_piece))
2615                    .collect();
2616                let om = Arc::new(PieceOrderMap::build(
2617                    &self.file_priorities,
2618                    &file_piece_ranges,
2619                    self.num_pieces,
2620                    0,
2621                    self.piece_ordering(),
2622                ));
2623                self.order_map_tx.send_replace(om);
2624            }
2625
2626            let notify = Arc::new(tokio::sync::Notify::new());
2627            self.reservation_notify = Some(notify);
2628        }
2629
2630        // Spawn web seeds if not already seeding
2631        if self.state != TorrentState::Seeding {
2632            self.spawn_web_seeds();
2633            self.assign_pieces_to_web_seeds();
2634        }
2635
2636        // M147: Set up ConnectPool — semaphore gates connection attempts only.
2637        // Permits are released on HandshakeComplete, not held for peer lifetime.
2638        let connect_semaphore = Arc::new(tokio::sync::Semaphore::new(
2639            self.effective_max_connections(),
2640        ));
2641        self.connect_semaphore = Arc::clone(&connect_semaphore);
2642        self.connect_permits.clear();
2643        // M137: Create PeerStates with the adder's input channel.
2644        // v0.187.3: pull eviction-ban cap + duration from session settings so
2645        // user changes via apply_settings take effect on the next spawn.
2646        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
2647        let peer_states = Arc::new(crate::peer_states::PeerStates::new_with_config(
2648            queue_tx,
2649            self.config.eviction_ban_set_cap,
2650            std::time::Duration::from_secs(self.config.eviction_ban_duration_secs),
2651        ));
2652        self.peer_states = Some(Arc::clone(&peer_states));
2653        let (adder_connect_tx, adder_connect_rx) = mpsc::channel(64);
2654        self.connect_rx = Some(adder_connect_rx);
2655        // M147: ConnectPool semaphore gates connection attempts (released on handshake)
2656        tokio::spawn(peer_adder::peer_adder_task(
2657            queue_rx,
2658            Arc::clone(&connect_semaphore),
2659            Arc::clone(&peer_states),
2660            Arc::clone(&self.ban_manager),
2661            Arc::clone(&self.ip_filter),
2662            adder_connect_tx,
2663        ));
2664
2665        let mut unchoke_interval = tokio::time::interval(Duration::from_secs(10));
2666        let mut rate_interval = tokio::time::interval(Duration::from_secs(2));
2667        rate_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2668        let mut optimistic_interval = tokio::time::interval(Duration::from_secs(30));
2669        let mut refill_interval = tokio::time::interval(Duration::from_millis(100));
2670        let mut dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::ZERO));
2671        let mut suggest_interval = if self.config.suggest_mode {
2672            Some(tokio::time::interval(Duration::from_secs(30)))
2673        } else {
2674            None
2675        };
2676        // M136: 1s steal-queue maintenance tick.
2677        let mut turnover_interval = tokio::time::interval(Duration::from_secs(1));
2678        let mut pipeline_tick_interval = tokio::time::interval(Duration::from_secs(1));
2679        // M77: Skip missed ticks — safety-net notify should fire at most once/second
2680        pipeline_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2681        let mut end_game_tick_interval = tokio::time::interval(Duration::from_millis(200));
2682        end_game_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2683        let mut diag_interval = tokio::time::interval(Duration::from_secs(5));
2684        // M108: 30s connection success rate summary for variance diagnosis.
2685        let mut conn_stats_interval = tokio::time::interval(Duration::from_secs(30));
2686        // M107: 5s metadata piece timeout — only meaningful in FetchingMetadata state
2687        let mut metadata_timeout_interval = tokio::time::interval(Duration::from_secs(5));
2688        // M103: 50ms debounce for reactive snapshot (was 500ms fixed interval)
2689        // M147: 1s soft reap interval — disconnects connecting peers without TCP SYN-ACK
2690        let mut soft_reap_interval = tokio::time::interval(Duration::from_secs(1));
2691        // M148: 2s proactive eviction — breaks the catch-22 where LivePool fills with
2692        // deadweight and no HandshakeComplete events arrive to trigger eviction.
2693        let mut eviction_interval = tokio::time::interval(Duration::from_secs(2));
2694        eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2695
2696        // Don't fire immediately for the first tick
2697        unchoke_interval.tick().await;
2698        optimistic_interval.tick().await;
2699        refill_interval.tick().await;
2700        // Note: dht_requery_sleep uses Sleep (not Interval), no initial tick skip needed
2701        if let Some(ref mut si) = suggest_interval {
2702            si.tick().await; // skip initial tick
2703        }
2704        turnover_interval.tick().await;
2705        pipeline_tick_interval.tick().await;
2706        end_game_tick_interval.tick().await;
2707        diag_interval.tick().await;
2708        conn_stats_interval.tick().await;
2709        metadata_timeout_interval.tick().await;
2710        soft_reap_interval.tick().await;
2711        eviction_interval.tick().await;
2712
2713        // Initial tracker announce (Started event) — non-blocking, fires via select! arm
2714        // DHT announce (v4 + v6) — dual-swarm for hybrid torrents
2715        if self.state == TorrentState::Downloading && self.config.enable_dht {
2716            // Primary hash (v1 or best_v1)
2717            if let Some(dht) = self.current_dht()
2718                && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
2719            {
2720                warn!("DHT v4 announce failed: {e}");
2721            }
2722            if let Some(dht6) = self.current_dht_v6()
2723                && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
2724            {
2725                debug!("DHT v6 announce failed: {e}");
2726            }
2727            // Dual-swarm: also announce v2 hash (truncated) for hybrid torrents
2728            if self.info_hashes.is_hybrid()
2729                && let Some(v2) = self.info_hashes.v2
2730            {
2731                let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
2732                if v2_as_v1 != self.info_hash {
2733                    if let Some(dht) = self.current_dht()
2734                        && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
2735                    {
2736                        debug!("DHT v4 dual-swarm announce failed: {e}");
2737                    }
2738                    if let Some(dht6) = self.current_dht_v6()
2739                        && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
2740                    {
2741                        debug!("DHT v6 dual-swarm announce failed: {e}");
2742                    }
2743                }
2744            }
2745        }
2746
2747        // I2P accept loop: spawn a background task that feeds incoming I2P
2748        // connections back via a channel, so the select! arm can handle them.
2749        if self.config.enable_i2p
2750            && let Some(ref sam) = self.sam_session
2751        {
2752            let (tx, rx) = mpsc::channel(16);
2753            let sam = Arc::clone(sam);
2754            tokio::spawn(async move {
2755                loop {
2756                    match sam.accept().await {
2757                        Ok(stream) => {
2758                            if tx.send(stream).await.is_err() {
2759                                break; // torrent actor dropped
2760                            }
2761                        }
2762                        Err(e) => {
2763                            warn!("I2P accept error: {e}");
2764                            tokio::time::sleep(Duration::from_secs(5)).await;
2765                        }
2766                    }
2767                }
2768            });
2769            self.i2p_accept_rx = Some(rx);
2770        }
2771
2772        loop {
2773            tokio::select! {
2774                biased;
2775                // Events from peers — batch-drain to reduce select! overhead.
2776                // At 100 MB/s we get ~6K events/sec; processing one-by-one
2777                // means 6K select! iterations with waker re-registration.
2778                // biased; ensures this high-throughput arm is checked first.
2779                event = self.event_rx.recv() => {
2780                    if let Some(event) = event {
2781                        // M182: ping the per-peer event_drain Notify so
2782                        // the reader's BackpressureQueue retries any
2783                        // spilled events. Looked up from peers by
2784                        // event.peer_addr (most variants carry one);
2785                        // events without a peer (PexPeers,
2786                        // TrackersReceived, WebSeed*) skip the ping —
2787                        // they don't fill the per-peer event channel.
2788                        Self::ping_event_drain(&self.peers, &event);
2789                        self.handle_peer_event(event)
2790                            .instrument(tracing::debug_span!("handle_peer_event"))
2791                            .await;
2792                        // Drain up to 512 more ready events without re-entering select!
2793                        for _ in 0..512 {
2794                            match self.event_rx.try_recv() {
2795                                Ok(event) => {
2796                                    Self::ping_event_drain(&self.peers, &event);
2797                                    self.handle_peer_event(event).await;
2798                                }
2799                                Err(_) => break,
2800                            }
2801                        }
2802                    }
2803                }
2804                // Async piece verification results
2805                Some(result) = self.verify_result_rx.recv() => {
2806                    self.pending_verify.remove(&result.piece);
2807                    // Guard: ignore stale/duplicate results for already-verified pieces
2808                    let dominated = self.chunk_tracker.as_ref()
2809                        .is_some_and(|ct| ct.bitfield().get(result.piece));
2810                    if !dominated {
2811                        if result.passed {
2812                            self.on_piece_verified(result.piece).await;
2813                        } else {
2814                            self.on_piece_hash_failed(result.piece).await;
2815                            // M73: Drivers pick up released pieces automatically via shared state
2816                        }
2817                    }
2818                }
2819                // M96: Hash pool verification results
2820                Some(result) = self.hash_result_rx.recv() => {
2821                    self.handle_hash_result(result).await;
2822                }
2823                // Commands from handle
2824                cmd = self.cmd_rx.recv() => {
2825                    match cmd {
2826                        Some(TorrentCommand::AddPeers { peers, source }) => {
2827                            self.handle_add_peers(peers, source);
2828                        }
2829                        Some(TorrentCommand::Stats { reply }) => {
2830                            let _ = reply.send(self.make_stats());
2831                        }
2832                        Some(TorrentCommand::Pause) => {
2833                            self.handle_pause().await;
2834                        }
2835                        Some(TorrentCommand::Queue) => {
2836                            self.handle_queue();
2837                        }
2838                        Some(TorrentCommand::Resume) => {
2839                            self.handle_resume().await;
2840                        }
2841                        Some(TorrentCommand::ForceResume) => {
2842                            self.user_forced = true;
2843                            self.handle_resume().await;
2844                        }
2845                        Some(TorrentCommand::SetCategory { category, reply }) => {
2846                            // M170: update the per-torrent category label.
2847                            // Marks resume as dirty so the next periodic
2848                            // save captures it.
2849                            self.config.category = category;
2850                            self.need_save_resume = true;
2851                            let _ = reply.send(());
2852                        }
2853                        Some(TorrentCommand::SetTags { tags, reply }) => {
2854                            // M171: replace the per-torrent tag set. Marks
2855                            // resume as dirty so the next periodic save
2856                            // captures it. `make_stats()` reads
2857                            // `self.config.tags` directly so the change is
2858                            // immediately visible to the next `stats()`
2859                            // call.
2860                            self.config.tags = tags;
2861                            self.need_save_resume = true;
2862                            let _ = reply.send(());
2863                        }
2864                        Some(TorrentCommand::GetWebSeeds { reply }) => {
2865                            // M171 Lane B: union of BEP 19 `url-list` and
2866                            // BEP 17 `httpseeds`. Order: BEP 19 first, then
2867                            // BEP 17 — matches the wire order. Returns an
2868                            // empty vec when metadata hasn't resolved yet
2869                            // (magnet still fetching info dict).
2870                            let urls = match &self.meta {
2871                                Some(meta) => {
2872                                    let mut v = Vec::with_capacity(
2873                                        meta.url_list.len() + meta.httpseeds.len(),
2874                                    );
2875                                    v.extend(meta.url_list.iter().cloned());
2876                                    v.extend(meta.httpseeds.iter().cloned());
2877                                    v
2878                                }
2879                                None => Vec::new(),
2880                            };
2881                            let _ = reply.send(urls);
2882                        }
2883                        Some(TorrentCommand::GetPieceStates { reply }) => {
2884                            // M171 Lane B: snapshot per-piece state as qBt
2885                            // codes. Returns an empty vec when metadata
2886                            // hasn't resolved (piece count unknown).
2887                            let states = match self.atomic_states.as_ref() {
2888                                Some(atomic) => atomic.snapshot(),
2889                                None => Vec::new(),
2890                            };
2891                            let _ = reply.send(states);
2892                        }
2893                        Some(TorrentCommand::GetPieceHashes { offset, limit, reply }) => {
2894                            // M171 Lane B: v1 piece hashes live in
2895                            // `meta.info.pieces` (20-byte SHA-1 concat);
2896                            // v2-only piece hashes live in
2897                            // `meta_v2.piece_layers` values (32-byte
2898                            // SHA-256 concat). Hybrid prefers v1 because
2899                            // the qBt client ecosystem treats v1 as the
2900                            // canonical hash surface.
2901                            //
2902                            // M245 L3: select the RAW bytes for the requested
2903                            // window ONLY (cheap chunk-slice clones of resident
2904                            // metadata) and hand them back unencoded. The old
2905                            // path hex-encoded EVERY hash on this recv loop and
2906                            // then threw all but the window away; the
2907                            // `hex::encode` now happens in the handle method off
2908                            // the loop. `skip(offset).take(limit)` reproduces the
2909                            // old `[offset, offset+limit)`-clamped-to-len window.
2910                            let offset = offset as usize;
2911                            let limit = limit as usize;
2912                            let raw: Vec<Vec<u8>> = match self.version {
2913                                irontide_core::TorrentVersion::V1Only
2914                                | irontide_core::TorrentVersion::Hybrid => self
2915                                    .meta
2916                                    .as_ref()
2917                                    .map(|meta| {
2918                                        meta.info
2919                                            .pieces
2920                                            .chunks_exact(20)
2921                                            .skip(offset)
2922                                            .take(limit)
2923                                            .map(<[u8]>::to_vec)
2924                                            .collect::<Vec<Vec<u8>>>()
2925                                    })
2926                                    .unwrap_or_default(),
2927                                irontide_core::TorrentVersion::V2Only => self
2928                                    .meta_v2
2929                                    .as_ref()
2930                                    .map(|m| {
2931                                        m.piece_layers
2932                                            .values()
2933                                            .flat_map(|v| v.chunks_exact(32))
2934                                            .skip(offset)
2935                                            .take(limit)
2936                                            .map(<[u8]>::to_vec)
2937                                            .collect::<Vec<Vec<u8>>>()
2938                                    })
2939                                    .unwrap_or_default(),
2940                            };
2941                            let _ = reply.send(raw);
2942                        }
2943                        Some(TorrentCommand::SaveResumeData { reply }) => {
2944                            let result = self.build_resume_data();
2945                            let _ = reply.send(result);
2946                        }
2947                        Some(TorrentCommand::TakeResumeIfDirty { reply }) => {
2948                            // M245 F1 — atomic take. ATOMICITY GUARD: there must
2949                            // be NO `.await` between reading `need_save_resume`
2950                            // and clearing it. The actor processes commands
2951                            // serially, so this whole arm is one indivisible
2952                            // turn — `build_resume_data()` is synchronous (no
2953                            // yield). Any `need_save_resume = true` set by
2954                            // another command lands strictly BEFORE this turn
2955                            // (captured here) or strictly AFTER (preserved for
2956                            // the next cycle); it can never be lost to a clear
2957                            // that races the build, the pre-M245 stats→save→
2958                            // clear three-step bug. The flag is cleared ONLY on
2959                            // a successful build, so a build error keeps the
2960                            // torrent dirty for retry.
2961                            let result = if self.need_save_resume {
2962                                let built = self.build_resume_data();
2963                                if built.is_ok() {
2964                                    self.need_save_resume = false;
2965                                }
2966                                built.map(Some)
2967                            } else {
2968                                Ok(None)
2969                            };
2970                            let _ = reply.send(result);
2971                        }
2972                        Some(TorrentCommand::SetFilePriority { index, priority, reply }) => {
2973                            // M246: range-scope the synchronous correctness-bearing passes
2974                            // (wanted_pieces / atomic_states / piece_tracker — they gate
2975                            // dispatch and must be coherent the instant the reply is sent)
2976                            // to the changed file's pieces, and DEFER the advisory global
2977                            // order-map rebuild to the coalescing 1 s pipeline tick by
2978                            // setting `order_map_dirty`, instead of running four
2979                            // O(num_pieces) passes on the recv loop. A GUI batch of M files
2980                            // (M back-to-back commands) now sets the flag M times but
2981                            // triggers a single rebuild.
2982                            match self.apply_file_priority_scoped(index, priority) {
2983                                Ok((first, last)) => {
2984                                    self.sync_piece_states_for_range(first, last);
2985                                    if let Some(ref mut pt) = self.piece_tracker {
2986                                        for piece in first..=last {
2987                                            if self.wanted_pieces.get(piece) {
2988                                                pt.mark_wanted(piece);
2989                                            } else {
2990                                                pt.mark_unwanted(piece);
2991                                            }
2992                                        }
2993                                    }
2994                                    self.order_map_dirty = true;
2995                                    let _ = reply.send(Ok(()));
2996                                }
2997                                Err(e) => {
2998                                    let _ = reply.send(Err(e));
2999                                }
3000                            }
3001                        }
3002                        Some(TorrentCommand::FilePriorities { reply }) => {
3003                            let _ = reply.send(self.file_priorities.clone());
3004                        }
3005                        Some(TorrentCommand::ForceReannounce) => {
3006                            self.tracker_manager.force_reannounce();
3007                        }
3008                        Some(TorrentCommand::TrackerList { reply }) => {
3009                            let _ = reply.send(self.tracker_manager.tracker_list());
3010                        }
3011                        Some(TorrentCommand::Scrape { reply }) => {
3012                            let result = self.tracker_manager.scrape().await;
3013                            if let Some((ref url, ref info)) = result {
3014                                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::ScrapeReply {
3015                                    info_hash: self.info_hash,
3016                                    url: url.clone(),
3017                                    complete: info.complete,
3018                                    incomplete: info.incomplete,
3019                                    downloaded: info.downloaded,
3020                                });
3021                            }
3022                            let _ = reply.send(result);
3023                        }
3024                        Some(TorrentCommand::OpenFile { file_index, reply }) => {
3025                            let result = self.handle_open_file(file_index);
3026                            let _ = reply.send(result);
3027                        }
3028                        Some(TorrentCommand::IncomingPeer { stream, addr }) => {
3029                            self.spawn_peer_from_stream_with_mode(
3030                                addr,
3031                                stream,
3032                                Some(irontide_wire::mse::EncryptionMode::Disabled),
3033                            );
3034                        }
3035                        Some(TorrentCommand::UpdateExternalIp { ip }) => {
3036                            self.external_ip = Some(ip);
3037                            post_alert(
3038                                &self.alert_tx,
3039                                &self.alert_mask,
3040                                AlertKind::ExternalIpDetected { ip },
3041                            );
3042                        }
3043                        Some(TorrentCommand::MoveStorage { new_path, reply }) => {
3044                            let result = self.handle_move_storage(new_path).await;
3045                            let _ = reply.send(result);
3046                        }
3047                        Some(TorrentCommand::SpawnSslPeer { addr, stream }) => {
3048                            // TLS is already completed; encryption is handled by TLS layer
3049                            self.spawn_peer_from_stream_with_mode(
3050                                addr,
3051                                stream.0,
3052                                Some(irontide_wire::mse::EncryptionMode::Disabled),
3053                            );
3054                        }
3055                        Some(TorrentCommand::SetDownloadLimit { bytes_per_sec, reply }) => {
3056                            self.download_bucket.lock().set_rate(bytes_per_sec);
3057                            let _ = reply.send(());
3058                        }
3059                        Some(TorrentCommand::SetUploadLimit { bytes_per_sec, reply }) => {
3060                            self.upload_bucket.set_rate(bytes_per_sec);
3061                            let _ = reply.send(());
3062                        }
3063                        Some(TorrentCommand::DownloadLimit { reply }) => {
3064                            let _ = reply.send(self.download_bucket.lock().rate());
3065                        }
3066                        Some(TorrentCommand::UploadLimit { reply }) => {
3067                            let _ = reply.send(self.upload_bucket.rate());
3068                        }
3069                        Some(TorrentCommand::SetSequentialDownload { enabled, reply }) => {
3070                            if self.config.sequential_download != enabled {
3071                                self.config.sequential_download = enabled;
3072                                // M253: ordering is consumed by the advisory
3073                                // order map — invalidate for the next tick.
3074                                self.order_map_dirty = true;
3075                            }
3076                            let _ = reply.send(());
3077                        }
3078                        Some(TorrentCommand::IsSequentialDownload { reply }) => {
3079                            let _ = reply.send(self.config.sequential_download);
3080                        }
3081                        Some(TorrentCommand::SetPrioritizeFirstLastPieces { enabled, reply }) => {
3082                            if self.config.prioritize_first_last_pieces != enabled {
3083                                self.config.prioritize_first_last_pieces = enabled;
3084                                self.order_map_dirty = true;
3085                            }
3086                            let _ = reply.send(());
3087                        }
3088                        Some(TorrentCommand::IsPrioritizeFirstLastPieces { reply }) => {
3089                            let _ = reply.send(self.config.prioritize_first_last_pieces);
3090                        }
3091                        Some(TorrentCommand::SetSuperSeeding { enabled, reply }) => {
3092                            self.config.super_seeding = enabled;
3093                            self.super_seed = if enabled {
3094                                Some(crate::super_seed::SuperSeedState::new())
3095                            } else {
3096                                None
3097                            };
3098                            let _ = reply.send(());
3099                        }
3100                        Some(TorrentCommand::IsSuperSeeding { reply }) => {
3101                            let _ = reply.send(self.config.super_seeding);
3102                        }
3103                        Some(TorrentCommand::SetSeedMode { enabled, reply }) => {
3104                            self.handle_set_seed_mode(enabled);
3105                            let _ = reply.send(());
3106                        }
3107                        Some(TorrentCommand::SetSeedRatioLimit { limit, reply }) => {
3108                            self.config.seed_ratio_limit = limit;
3109                            self.need_save_resume = true;
3110                            let _ = reply.send(());
3111                        }
3112                        Some(TorrentCommand::AddTracker { url }) => {
3113                            self.tracker_manager.add_tracker_url(&url);
3114                        }
3115                        Some(TorrentCommand::ReplaceTrackers { urls, reply }) => {
3116                            self.tracker_manager.replace_all(&urls);
3117                            let _ = reply.send(());
3118                        }
3119                        Some(TorrentCommand::ForceRecheck { reply }) => {
3120                            self.handle_force_recheck(reply).await;
3121                        }
3122                        Some(TorrentCommand::RenameFile { file_index, new_name, reply }) => {
3123                            let result = self.handle_rename_file(file_index, new_name).await;
3124                            let _ = reply.send(result);
3125                        }
3126                        Some(TorrentCommand::SetMaxConnections { limit, reply }) => {
3127                            self.max_connections = limit;
3128                            let _ = reply.send(());
3129                        }
3130                        Some(TorrentCommand::MaxConnections { reply }) => {
3131                            let _ = reply.send(self.max_connections);
3132                        }
3133                        Some(TorrentCommand::SetMaxUploads { limit, reply }) => {
3134                            self.choker.set_unchoke_slots(limit);
3135                            let _ = reply.send(());
3136                        }
3137                        Some(TorrentCommand::MaxUploads { reply }) => {
3138                            let _ = reply.send(self.choker.unchoke_slots());
3139                        }
3140                        Some(TorrentCommand::GetPeerInfo { reply }) => {
3141                            let _ = reply.send(self.build_peer_info());
3142                        }
3143                        Some(TorrentCommand::GetDownloadQueue { reply }) => {
3144                            let _ = reply.send(self.build_download_queue());
3145                        }
3146                        Some(TorrentCommand::HavePiece { index, reply }) => {
3147                            let has = self.chunk_tracker.as_ref()
3148                                .is_some_and(|ct| ct.has_piece(index));
3149                            let _ = reply.send(has);
3150                        }
3151                        Some(TorrentCommand::PieceAvailability { reply }) => {
3152                            // M246 (D5): this O(num_peers * num_pieces) scan is the
3153                            // only super-linear residual handler, but it is a RARE
3154                            // on-demand read query — its sole caller is the public
3155                            // `piece_availability()` accessor, driven by the client's
3156                            // GUI/WebUI piece-availability bar, never per-tick or
3157                            // per-message. Incremental maintenance (hooking every
3158                            // connect / disconnect / HAVE to keep a running counter)
3159                            // is deliberately out of the L4 recv-loop-hardening scope:
3160                            // it would add steady-state cost to the hot path to speed
3161                            // up a query a human triggers a few times a minute.
3162                            let mut avail = vec![0u32; self.num_pieces as usize];
3163                            for peer in self.peers.values() {
3164                                for i in 0..self.num_pieces {
3165                                    if peer.bitfield.get(i) {
3166                                        avail[i as usize] += 1;
3167                                    }
3168                                }
3169                            }
3170                            let _ = reply.send(avail);
3171                        }
3172                        Some(TorrentCommand::FileProgress { reply }) => {
3173                            let _ = reply.send(self.compute_file_progress());
3174                        }
3175                        Some(TorrentCommand::InfoHashes { reply }) => {
3176                            let _ = reply.send(self.info_hashes.clone());
3177                        }
3178                        Some(TorrentCommand::TorrentFile { reply }) => {
3179                            let _ = reply.send(self.meta.clone());
3180                        }
3181                        Some(TorrentCommand::TorrentFileV2 { reply }) => {
3182                            let _ = reply.send(self.meta_v2.clone());
3183                        }
3184                        Some(TorrentCommand::ForceDhtAnnounce) => {
3185                            self.handle_force_dht_announce().await;
3186                        }
3187                        Some(TorrentCommand::ReadPiece { index, reply }) => {
3188                            let result = self.handle_read_piece(index).await;
3189                            let _ = reply.send(result);
3190                        }
3191                        Some(TorrentCommand::FlushCache { reply }) => {
3192                            let result = self.handle_flush_cache().await;
3193                            let _ = reply.send(result);
3194                        }
3195                        Some(TorrentCommand::ClearError) => {
3196                            self.handle_clear_error().await;
3197                        }
3198                        Some(TorrentCommand::ClearSaveResumeFlag) => {
3199                            self.need_save_resume = false;
3200                        }
3201                        Some(TorrentCommand::MarkResumeDirty) => {
3202                            // M245 F1 — re-arm after a failed resume WRITE so the
3203                            // torrent is retried next save cycle (see
3204                            // `TakeResumeIfDirty`).
3205                            self.need_save_resume = true;
3206                        }
3207                        Some(TorrentCommand::RestoreResumeBitmap { pieces, reply }) => {
3208                            let result = self.handle_restore_resume_bitmap(pieces);
3209                            let _ = reply.send(result);
3210                        }
3211                        Some(TorrentCommand::RestoreWebSeedStats { stats, reply }) => {
3212                            self.web_seed_stats = stats;
3213                            let _ = reply.send(Ok(()));
3214                        }
3215                        Some(TorrentCommand::GetPeerSourceCounts { reply }) => {
3216                            let _ = reply.send((self.pex_peer_count, self.lsd_peer_count));
3217                        }
3218                        Some(TorrentCommand::QueryUnchokeDurations { reply }) => {
3219                            let mut out = self.unchoke_durations.clone();
3220                            // Merge in each currently-live peer's transient
3221                            // accumulator + any in-flight unchoke window.
3222                            let now = Instant::now();
3223                            for peer in self.peers.values() {
3224                                let mut delta = peer.unchoke_duration_total;
3225                                if let Some(start) = peer.am_unchoke_started_at {
3226                                    delta += now.duration_since(start);
3227                                }
3228                                if !delta.is_zero() {
3229                                    *out.entry(peer.addr).or_default() += delta;
3230                                }
3231                            }
3232                            let _ = reply.send(out);
3233                        }
3234                        Some(TorrentCommand::GetWebSeedStats { reply }) => {
3235                            let snapshot: Vec<_> = self.web_seed_stats.values().cloned().collect();
3236                            let _ = reply.send(snapshot);
3237                        }
3238                        Some(TorrentCommand::FileStatus { reply }) => {
3239                            let _ = reply.send(self.build_file_status());
3240                        }
3241                        Some(TorrentCommand::Flags { reply }) => {
3242                            let _ = reply.send(self.build_flags());
3243                        }
3244                        Some(TorrentCommand::SetFlags { flags, reply }) => {
3245                            self.apply_set_flags(flags).await;
3246                            let _ = reply.send(());
3247                        }
3248                        Some(TorrentCommand::UnsetFlags { flags, reply }) => {
3249                            self.apply_unset_flags(flags).await;
3250                            let _ = reply.send(());
3251                        }
3252                        Some(TorrentCommand::ConnectPeer { addr }) => {
3253                            self.handle_connect_peer(addr);
3254                        }
3255                        Some(TorrentCommand::PreResolvedMetadata { info_bytes, peers }) => {
3256                            self.handle_pre_resolved_metadata(info_bytes, peers).await;
3257                        }
3258                        #[cfg(feature = "test-util")]
3259                        Some(TorrentCommand::TestInjectMetadata { info_bytes, reply }) => {
3260                            // Reuses the existing handle_pre_resolved_metadata at torrent.rs:3665.
3261                            // Synchronous because the test caller depends on completion before
3262                            // proceeding (unlike the production resolver which is fire-and-forget).
3263                            self.handle_pre_resolved_metadata(info_bytes, vec![]).await;
3264                            let _ = reply.send(());
3265                        }
3266                        Some(TorrentCommand::GetMeta { reply }) => {
3267                            // v0.173.1: single source of truth for torrent
3268                            // metadata — replaces `SessionActor.TorrentEntry.meta`
3269                            // so magnet-added torrents no longer lose the info
3270                            // dict between the TorrentActor and the session.
3271                            let _ = reply.send(self.meta.clone());
3272                        }
3273                        Some(TorrentCommand::UpdateSettings(delta)) => {
3274                            self.handle_update_settings(&delta);
3275                        }
3276                        Some(TorrentCommand::Shutdown) => {
3277                            info!("torrent actor: received Shutdown command, exiting");
3278                            self.shutdown_web_seeds().await;
3279                            self.shutdown_peers().await;
3280                            return;
3281                        }
3282                        None => {
3283                            warn!("torrent actor: cmd_rx channel closed (all senders dropped), exiting");
3284                            self.shutdown_web_seeds().await;
3285                            self.shutdown_peers().await;
3286                            return;
3287                        }
3288                    }
3289                }
3290                // Async disk write errors
3291                Some(err) = self.write_error_rx.recv() => {
3292                    warn!(piece = err.piece, begin = err.begin, "async disk write failed: {}", err.error);
3293                }
3294                // Accept incoming peers
3295                result = accept_incoming(&mut self.listener) => {
3296                    if let Ok((stream, addr)) = result {
3297                        self.spawn_peer_from_stream(addr, stream);
3298                    }
3299                }
3300                // Accept incoming I2P peers (M41)
3301                stream = accept_i2p(&mut self.i2p_accept_rx) => {
3302                    if let Some(stream) = stream {
3303                        self.handle_i2p_incoming(stream);
3304                    }
3305                }
3306                // Rate update timer (2s) — decoupled from the 10s unchoke
3307                // interval so the GUI sees responsive DL/UL numbers.
3308                _ = rate_interval.tick() => {
3309                    self.update_peer_rates();
3310                }
3311                // Unchoke timer
3312                _ = unchoke_interval.tick() => {
3313                    // M144 deviation from BEP 3 §3 (10s choking algorithm):
3314                    // We skip the choker during download — intentional rqbit-parity
3315                    // decision. BEP 3 specifies a 10s choking interval to manage
3316                    // upload slots via tit-for-tat. But IronTide unchokes all peers
3317                    // unconditionally on connect (M107); running the choker every
3318                    // 10s re-chokes most peers, breaking the reciprocity that drove
3319                    // the original unchoke. Only run during seeding/sharing where
3320                    // upload-slot management matters. Reviewed M174.
3321                    if self.state == TorrentState::Seeding
3322                        || self.state == TorrentState::Sharing
3323                    {
3324                        self.slot_tuner.observe(self.upload_bytes_interval);
3325                        self.choker.observe_throughput(self.upload_bytes_interval);
3326                        self.upload_bytes_interval = 0;
3327                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3328                        self.run_choker().await;
3329                        // v0.187.3: a normal tick satisfies the immediate-tick
3330                        // request, so clear the flag.
3331                        self.force_immediate_choker_tick = false;
3332                    } else {
3333                        self.upload_bytes_interval = 0;
3334                    }
3335                    // Update streaming cursors and piece priorities
3336                    self.update_streaming_cursors();
3337                    // Update auto-sequential hysteresis (M45)
3338                    if self.config.auto_sequential {
3339                        let was = self.auto_sequential_active;
3340                        self.auto_sequential_active = crate::piece_selector::evaluate_auto_sequential(
3341                            self.piece_owner.iter().filter(|o| o.is_some()).count(),
3342                            self.peers.len(),
3343                            self.auto_sequential_active,
3344                        );
3345                        // M253 (D4): a hysteresis flip re-shapes the advisory
3346                        // walk order on the next coalesced rebuild.
3347                        if was != self.auto_sequential_active {
3348                            self.order_map_dirty = true;
3349                        }
3350                    }
3351                    // Periodic web seed piece reassignment (moved from dht_recheck timer)
3352                    self.assign_pieces_to_web_seeds();
3353                }
3354                // Optimistic unchoke timer
3355                _ = optimistic_interval.tick() => {
3356                    self.rotate_optimistic();
3357                }
3358                // M107: Receive connect requests from the peer adder task
3359                Some(connect_peer) = async {
3360                    match self.connect_rx.as_mut() {
3361                        Some(rx) => rx.recv().await,
3362                        None => std::future::pending().await,
3363                    }
3364                } => {
3365                    self.handle_adder_connect(connect_peer);
3366                }
3367                () = &mut dht_requery_sleep, if self.state != TorrentState::Complete
3368                    && self.state != TorrentState::Paused
3369                    && self.state != TorrentState::Queued
3370                    && self.state != TorrentState::Seeding
3371                    && self.state != TorrentState::Stopped => {
3372                    self.run_dht_requery().await;
3373                    dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::from_mins(1)));
3374                }
3375                // M143: Tracker re-announce timer — starts a background announce,
3376                // never blocks. Only fires when no announce is in-flight.
3377                // Also fires during FetchingMetadata so magnets with &tr= URLs
3378                // can discover peers before metadata arrives.
3379                () = async {
3380                    match self.tracker_manager.next_announce_in() {
3381                        Some(dur) => tokio::time::sleep(dur).await,
3382                        None => std::future::pending().await,
3383                    }
3384                }, if self.tracker_result_rx.is_none() => {
3385                    let left = self.calculate_left();
3386                    self.tracker_result_rx = Some(self.tracker_manager.start_announce(
3387                        irontide_tracker::AnnounceEvent::None,
3388                        self.uploaded,
3389                        self.downloaded,
3390                        left,
3391                    ));
3392                }
3393                // M143: Streaming tracker results — process each tracker
3394                // response as it arrives, without blocking the actor loop.
3395                result = async {
3396                    match self.tracker_result_rx.as_mut() {
3397                        Some(rx) => rx.recv().await,
3398                        None => std::future::pending().await,
3399                    }
3400                } => {
3401                    match result {
3402                        Some(batch) => {
3403                            let (peers, outcome) = self.tracker_manager.process_tracker_result(batch);
3404                            self.fire_tracker_alerts(&[outcome]);
3405                            if !peers.is_empty() {
3406                                debug!(count = peers.len(), "tracker returned peers (streaming)");
3407                                self.handle_add_peers(peers, PeerSource::Tracker);
3408                            }
3409                        }
3410                        None => {
3411                            // All trackers responded — clear in-flight state so
3412                            // the timer arm can re-fire for the next announce cycle.
3413                            self.tracker_result_rx = None;
3414                        }
3415                    }
3416                }
3417                // DHT v4 peer discovery
3418                result = async {
3419                    match &mut self.dht_peers_rx {
3420                        Some(rx) => rx.recv().await,
3421                        None => std::future::pending().await,
3422                    }
3423                } => {
3424                    if let Some(peers) = result {
3425                        debug!(count = peers.len(), "DHT v4 returned peers");
3426                        self.handle_add_peers(peers, PeerSource::Dht);
3427                    } else {
3428                        debug!("DHT v4 peer search exhausted");
3429                        self.dht_peers_rx = None;
3430                    }
3431                }
3432                // DHT v6 peer discovery
3433                result = async {
3434                    match &mut self.dht_v6_peers_rx {
3435                        Some(rx) => rx.recv().await,
3436                        None => std::future::pending().await,
3437                    }
3438                } => {
3439                    if let Some(peers) = result {
3440                        debug!(count = peers.len(), "DHT v6 returned peers");
3441                        self.dht_v6_empty_count = 0; // V6 is working, reset
3442                        self.handle_add_peers(peers, PeerSource::Dht);
3443                    } else {
3444                        self.dht_v6_peers_rx = None;
3445                        self.dht_v6_empty_count += 1;
3446                        if self.dht_v6_empty_count == 30 {
3447                            debug!("DHT v6 routing table persistently empty, giving up");
3448                        } else if self.dht_v6_empty_count < 30 {
3449                            debug!("DHT v6 peer search exhausted");
3450                        }
3451                    }
3452                }
3453                // Dual-swarm: DHT v4 v2-hash peer discovery (hybrid)
3454                result = async {
3455                    match &mut self.dht_v2_peers_rx {
3456                        Some(rx) => rx.recv().await,
3457                        None => std::future::pending().await,
3458                    }
3459                } => {
3460                    if let Some(peers) = result {
3461                        debug!(count = peers.len(), "DHT v4 v2-swarm returned peers");
3462                        self.handle_add_peers(peers, PeerSource::Dht);
3463                    } else {
3464                        debug!("DHT v4 v2-swarm peer search exhausted");
3465                        self.dht_v2_peers_rx = None;
3466                    }
3467                }
3468                // Dual-swarm: DHT v6 v2-hash peer discovery (hybrid)
3469                result = async {
3470                    match &mut self.dht_v6_v2_peers_rx {
3471                        Some(rx) => rx.recv().await,
3472                        None => std::future::pending().await,
3473                    }
3474                } => {
3475                    if let Some(peers) = result {
3476                        debug!(count = peers.len(), "DHT v6 v2-swarm returned peers");
3477                        self.handle_add_peers(peers, PeerSource::Dht);
3478                    } else {
3479                        debug!("DHT v6 v2-swarm peer search exhausted");
3480                        self.dht_v6_v2_peers_rx = None;
3481                    }
3482                }
3483                // M44: Suggest cached pieces timer
3484                _ = async {
3485                    match suggest_interval {
3486                        Some(ref mut interval) => interval.tick().await,
3487                        None => std::future::pending().await,
3488                    }
3489                } => {
3490                    self.suggest_cached_pieces().await;
3491                }
3492                _ = turnover_interval.tick() => {
3493                    self.run_steal_queue_maintenance();
3494                }
3495                // Pipeline tick (1s) — update EWMA, snub detection, peer scoring
3496                _ = pipeline_tick_interval.tick() => {
3497                    let snub_timeout = Duration::from_secs(u64::from(self.config.snub_timeout_secs));
3498
3499                    for (_addr, peer) in &mut self.peers {
3500                        peer.pipeline.tick();
3501
3502                        // Snub detection: no data for snub_timeout_secs while unchoked
3503                        if !peer.peer_choking && !peer.snubbed {
3504                            let idle = peer.last_data_received
3505                                .is_some_and(|t| t.elapsed() > snub_timeout);
3506                            if idle {
3507                                peer.snubbed = true;
3508                                // M106: Count pending requests as timed-out blocks
3509                                peer.blocks_timed_out = peer.blocks_timed_out
3510                                    .saturating_add(peer.pending_requests.len() as u64);
3511                                debug!(%_addr, "peer snubbed (no data for {}s)", self.config.snub_timeout_secs);
3512                            }
3513                        }
3514                    }
3515
3516                    // Refresh cached peer rates for steal decisions (avoids
3517                    // rebuilding a FxHashMap from all peers on every block arrival).
3518                    self.refresh_peer_rates();
3519
3520                    // M257c: reallocate the per-torrent request budget from
3521                    // the rates refreshed above (unconditional — budget 0
3522                    // emits legacy depths, so live-disable self-restores).
3523                    self.apply_request_budget();
3524
3525                    // M73: Periodic endgame activation check (was in batch_fill_all_peers)
3526                    if !self.end_game.is_active() {
3527                        self.check_end_game_activation();
3528                    }
3529
3530                    self.tick_dispatch_safety_wake();
3531
3532                    // M138: Proactive choke rotation — every tick, evict up to N choked peers
3533                    if self.config.choke_rotation_max_evictions > 0
3534                        && self.state == TorrentState::Downloading
3535                    {
3536                        self.run_choke_rotation();
3537                    }
3538
3539                    // M246: coalesced order-map rebuild. The SetFilePriority arm sets
3540                    // `order_map_dirty` instead of building inline; a batch of priority
3541                    // changes collapses to ONE rebuild here (at most ~1 s later — the
3542                    // order map is an advisory dispatch walk-order hint, and the
3543                    // piece_tracker already gates wanted/reserved synchronously).
3544                    if self.order_map_dirty {
3545                        self.rebuild_order_map_now();
3546                    }
3547                }
3548                // (M75: peer tasks handle dispatch via integrated select! arm)
3549                // End-game refill tick (200ms) — replace reactive per-block cascade
3550                // with periodic batch refill. All peers with available pipeline slots
3551                // get new end-game blocks, preventing idle stalls between ticks.
3552                _ = end_game_tick_interval.tick(), if self.end_game.is_active() => {
3553                    let addrs: Vec<SocketAddr> = self.peers.iter()
3554                        .filter(|(_, p)| !p.peer_choking && p.pending_requests.len() < END_GAME_DEPTH)
3555                        .map(|(addr, _)| *addr)
3556                        .collect();
3557                    for addr in addrs {
3558                        self.request_end_game_block(addr).await;
3559                    }
3560                }
3561                // M107: Metadata piece timeout — re-request timed-out pieces from
3562                // all non-rejected peers that support ut_metadata.
3563                _ = metadata_timeout_interval.tick(), if self.state == TorrentState::FetchingMetadata => {
3564                    // Collect timed-out pieces (immutable borrow, then release).
3565                    let timed_out: Vec<u32> = self
3566                        .metadata_downloader
3567                        .as_ref()
3568                        .map(MetadataDownloader::timed_out_pieces)
3569                        .unwrap_or_default();
3570
3571                    if !timed_out.is_empty() {
3572                        debug!(count = timed_out.len(), "metadata pieces timed out, re-requesting");
3573
3574                        // Collect eligible peers (non-rejected, support ut_metadata).
3575                        // Clone cmd_tx to avoid holding borrows across the send loop.
3576                        let eligible_senders: Vec<mpsc::Sender<PeerCommand>> = self
3577                            .peers
3578                            .iter()
3579                            .filter(|(addr, peer)| {
3580                                self.metadata_downloader
3581                                    .as_ref()
3582                                    .is_some_and(|dl| !dl.is_rejected(addr))
3583                                    && peer
3584                                        .ext_handshake
3585                                        .as_ref()
3586                                        .is_some_and(|h| h.metadata_size.is_some())
3587                            })
3588                            .map(|(_, peer)| peer.cmd_tx.clone())
3589                            .collect();
3590
3591                        // Send requests (uses cloned senders, no borrow conflict).
3592                        for cmd_tx in &eligible_senders {
3593                            for &piece in &timed_out {
3594                                let _ = cmd_tx.try_send(PeerCommand::RequestMetadata { piece });
3595                            }
3596                        }
3597
3598                        // Update request times in the downloader.
3599                        if let Some(ref mut dl) = self.metadata_downloader {
3600                            for piece in timed_out {
3601                                dl.reset_request_time(piece);
3602                            }
3603                        }
3604                    }
3605                }
3606                // Periodic download status report (5s)
3607                _ = diag_interval.tick() => {
3608                    // Heartbeat: log state regardless of download state
3609                    {
3610                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3611                        let eg = self.end_game.is_active();
3612                        let eg_blocks = self.end_game.block_count();
3613                        info!(state = ?self.state, have, total = self.num_pieces, end_game = eg, eg_blocks, "heartbeat");
3614                    }
3615                    if self.state == TorrentState::Downloading {
3616                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3617                        let in_flight = self.atomic_states.as_ref().map_or(0, |s| s.in_flight_count() as usize);
3618                        let unchoked = self.peers.values().filter(|p| !p.peer_choking).count();
3619                        info!(have, in_flight, total = self.num_pieces,
3620                              downloaded_mb = self.downloaded / (1024 * 1024),
3621                              peers = self.peers.len(), unchoked,
3622                              "download progress");
3623                        for (addr, p) in &self.peers {
3624                            let last_data = p.last_data_received.map_or(9999, |t| t.elapsed().as_secs());
3625                            trace!(%addr,
3626                                   choking = p.peer_choking,
3627                                   pending = p.pending_requests.len(),
3628                                   ewma_rate = p.pipeline.ewma_rate() as u64,
3629                                   last_data_secs = last_data,
3630                                   bf_ones = p.bitfield.count_ones(),
3631                                   "peer state");
3632                        }
3633                    }
3634                }
3635                // M108: 30s connection success rate summary for variance diagnosis
3636                _ = conn_stats_interval.tick() => {
3637                    if self.connect_attempts > 0 {
3638                        let succeeded = self.connect_attempts.saturating_sub(self.connect_failures);
3639                        let success_pct = (succeeded as f64 / self.connect_attempts as f64 * 100.0) as u32;
3640                        info!(
3641                            connected = self.peers.len(),
3642                            attempted = self.connect_attempts,
3643                            failed = self.connect_failures,
3644                            success_rate = %format!("{success_pct}%"),
3645                            "connection stats"
3646                        );
3647                    }
3648                }
3649                // M147: Soft reap — disconnect connecting peers without TCP SYN-ACK.
3650                // v0.173.3: uses the buffer-fill variant + index iteration to reuse
3651                // soft_reap_buf across ticks without ever moving its heap allocation.
3652                // SocketAddr is Copy, so indexing into self.soft_reap_buf yields a
3653                // value copy and does not borrow self.soft_reap_buf for the loop body.
3654                _ = soft_reap_interval.tick() => {
3655                    let soft_timeout = self.config.connect_soft_timeout;
3656                    if soft_timeout > 0 {
3657                        if let Some(ref ps) = self.peer_states {
3658                            ps.soft_reap_candidates_into(
3659                                Duration::from_secs(soft_timeout),
3660                                &mut self.soft_reap_buf,
3661                            );
3662                        } else {
3663                            self.soft_reap_buf.clear();
3664                        }
3665                        for i in 0..self.soft_reap_buf.len() {
3666                            let peer_addr = self.soft_reap_buf[i];
3667                            debug!(%peer_addr, soft_timeout, "soft reap: no TCP SYN-ACK");
3668                            // Remove from connect_permits so RAII drops the permit
3669                            self.connect_permits.remove(&peer_addr);
3670                            self.disconnect_peer(peer_addr, "soft reap: no TCP SYN-ACK");
3671                            if let Some(ref ps) = self.peer_states
3672                                && let Some(backoff) = ps.mark_dead(peer_addr)
3673                            {
3674                                let ps_clone = Arc::clone(ps);
3675                                tokio::spawn(async move {
3676                                    tokio::time::sleep(backoff).await;
3677                                    ps_clone.mark_queued_for_retry(peer_addr);
3678                                });
3679                            }
3680                        }
3681                        self.soft_reap_buf.clear();
3682                    }
3683                }
3684                // M148 + v0.187.3: Proactive eviction with churn guard.
3685                //
3686                //                  PROACTIVE EVICTION POLICY (M148 → v0.187.3)
3687                //                  ============================================
3688                //
3689                //   Tick every 2s
3690                //   ├─ state == Seeding?                          ──── no-op
3691                //   ├─ live < (effective_max * 0.95)?             ──── no-op
3692                //   └─ eviction_history (in last 60s) < limit?    ──── no-op
3693                //       │
3694                //       ▼
3695                //   for up to 5 candidates:
3696                //     find_eviction_candidate() →
3697                //       Pass 0  ZeroThroughput     [skipped if state==Seeding]
3698                //               [skipped if peer.live_since < pass0_grace_secs]
3699                //               ban 10min, push to banned_set (FIFO cap 1024)
3700                //       Pass 1  Choked > 10s       no ban
3701                //       Pass 2  LowThroughput      no ban
3702                //       Pass 3  Bep40 priority     [only on HandshakeComplete]
3703                //               no ban
3704                //
3705                //     on evict: eviction_history.push_back(now)
3706                _ = eviction_interval.tick() => {
3707                    // v0.187.3 / Bug 8a: opportunistically service a pending
3708                    // immediate-tick request from a recent state transition
3709                    // (typically Downloading → Seeding). Caps the worst-case
3710                    // first-unchoke latency at the 2s eviction interval
3711                    // instead of the 10s unchoke interval.
3712                    if self.force_immediate_choker_tick
3713                        && (self.state == TorrentState::Seeding
3714                            || self.state == TorrentState::Sharing)
3715                    {
3716                        self.slot_tuner.observe(self.upload_bytes_interval);
3717                        self.choker.observe_throughput(self.upload_bytes_interval);
3718                        self.upload_bytes_interval = 0;
3719                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3720                        self.run_choker().await;
3721                        self.force_immediate_choker_tick = false;
3722                    }
3723                    if self.state != TorrentState::Seeding {
3724                        // v0.187.3 / 3A: prune eviction_history of entries older
3725                        // than 60s, then gate on the configured limit.
3726                        let prune_cutoff = std::time::Duration::from_mins(1);
3727                        while self
3728                            .eviction_history
3729                            .front()
3730                            .copied()
3731                            .is_some_and(|t| t.elapsed() > prune_cutoff)
3732                        {
3733                            self.eviction_history.pop_front();
3734                        }
3735                        let limit = self.config.proactive_evictions_per_minute_limit as usize;
3736                        let window_ok = self.eviction_history.len() < limit;
3737
3738                        // v0.187.3 / Pressure gate: 0.95 (was 0.75 in v0.187.2).
3739                        // Higher threshold gives slow-start peers room to ramp
3740                        // before the eviction loop fires.
3741                        let should_evict = window_ok
3742                            && self.peer_states.as_ref().is_some_and(|ps| {
3743                                let live = ps
3744                                    .stats
3745                                    .live
3746                                    .load(std::sync::atomic::Ordering::Relaxed);
3747                                #[allow(
3748                                    clippy::cast_possible_truncation,
3749                                    clippy::cast_sign_loss
3750                                )]
3751                                let threshold =
3752                                    (self.effective_max_connections() as f32 * 0.95) as u32;
3753                                debug_assert!(
3754                                    self.effective_max_connections()
3755                                        <= crate::torrent_peers::HARD_PEER_CEILING,
3756                                    "effective_max must be clamped to HARD_PEER_CEILING"
3757                                );
3758                                live >= threshold
3759                            });
3760                        if should_evict {
3761                            // Evict up to 5 deadweight per tick, but no more than
3762                            // (limit - history.len()) total per the sliding window.
3763                            let max_this_tick = 5.min(limit.saturating_sub(self.eviction_history.len()));
3764                            for _ in 0..max_this_tick {
3765                                match self.find_eviction_candidate() {
3766                                    Some((victim, pass)) => {
3767                                        debug!(%victim, ?pass, "v0.187.3 proactive eviction");
3768                                        self.disconnect_peer(victim, "proactive eviction");
3769                                        if matches!(pass, crate::torrent_peers::EvictionPass::ZeroThroughput)
3770                                            && let Some(ref ps) = self.peer_states
3771                                        {
3772                                            ps.add_eviction_ban(victim);
3773                                        }
3774                                        self.eviction_history.push_back(std::time::Instant::now());
3775                                    }
3776                                    None => break,
3777                                }
3778                            }
3779                        }
3780
3781                        // M149: Piece stealing scan (piggybacks on same 2s interval)
3782                        self.run_piece_steal_scan();
3783                    }
3784                }
3785                // Rate limiter refill (100ms)
3786                _ = refill_interval.tick() => {
3787                    let elapsed = Duration::from_millis(100);
3788                    self.upload_bucket.refill(elapsed);
3789                    self.download_bucket.lock().refill(elapsed);
3790                    // Refill per-class buckets and apply mixed-mode (M45)
3791                    self.rate_limiter_set.refill(elapsed);
3792                    let (tcp_peers, utp_peers) = self.transport_peer_counts();
3793                    self.rate_limiter_set.apply_mixed_mode(
3794                        self.config.mixed_mode_algorithm,
3795                        tcp_peers,
3796                        utp_peers,
3797                        self.config.upload_rate_limit,
3798                    );
3799                }
3800            }
3801
3802            // M112: drain holepunch attempts (bridging sync disconnect_peer → async try_holepunch)
3803            for target in std::mem::take(&mut self.holepunch_pending) {
3804                self.try_holepunch(target).await;
3805            }
3806        }
3807    }
3808
3809    // ----- Command handlers -----
3810
3811    /// Compute distributed copy availability across the swarm.
3812    ///
3813    /// Returns `(full_copies, fraction, copies_float)` where `fraction` is in thousandths.
3814    pub(crate) fn distributed_copies(&self) -> (u32, u32, f32) {
3815        if self.num_pieces == 0 || self.peers.is_empty() {
3816            return (0, 0, 0.0);
3817        }
3818
3819        let num = self.num_pieces as usize;
3820        let mut availability = vec![0u32; num];
3821
3822        for peer in self.peers.values() {
3823            for idx in 0..self.num_pieces {
3824                if peer.bitfield.get(idx) {
3825                    availability[idx as usize] += 1;
3826                }
3827            }
3828        }
3829
3830        let min_avail = availability.iter().copied().min().unwrap_or(0);
3831        let rarest_count = availability.iter().filter(|&&c| c == min_avail).count() as u32;
3832        let fraction = ((self.num_pieces - rarest_count) * 1000) / self.num_pieces;
3833        let copies_float = min_avail as f32 + fraction as f32 / 1000.0;
3834
3835        (min_avail, fraction, copies_float)
3836    }
3837
3838    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3839    /// `GetDownloadQueue` query) — intentionally left on the recv loop; it has
3840    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3841    fn build_download_queue(&self) -> Vec<PartialPieceInfo> {
3842        self.piece_owner
3843            .iter()
3844            .enumerate()
3845            .filter_map(|(piece_index, owner)| {
3846                owner.map(|_| {
3847                    let piece_index = piece_index as u32;
3848                    let blocks_in_piece = self
3849                        .lengths
3850                        .as_ref()
3851                        .map_or(0, |l| l.piece_size(piece_index).div_ceil(l.chunk_size()));
3852                    PartialPieceInfo {
3853                        piece_index,
3854                        blocks_in_piece,
3855                        blocks_assigned: 0,
3856                    }
3857                })
3858            })
3859            .collect()
3860    }
3861
3862    /// Compute per-file downloaded bytes.
3863    ///
3864    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3865    /// `FileProgress` query) — intentionally left on the recv loop; it has
3866    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3867    fn compute_file_progress(&self) -> Vec<u64> {
3868        let Some(meta) = self.meta.as_ref() else {
3869            return Vec::new();
3870        };
3871        let Some(lengths) = self.lengths.as_ref() else {
3872            return Vec::new();
3873        };
3874        let Some(chunk_tracker) = self.chunk_tracker.as_ref() else {
3875            return Vec::new();
3876        };
3877
3878        let files = meta.info.files();
3879        if files.is_empty() {
3880            return Vec::new();
3881        }
3882
3883        let piece_length = lengths.piece_length();
3884        let mut result = Vec::with_capacity(files.len());
3885        let mut file_offset = 0u64;
3886
3887        for file_entry in &files {
3888            let file_len = file_entry.length;
3889            if file_len == 0 {
3890                result.push(0);
3891                file_offset += file_len;
3892                continue;
3893            }
3894
3895            let file_end = file_offset + file_len;
3896            let first_piece = (file_offset / piece_length) as u32;
3897            let last_piece = ((file_end - 1) / piece_length) as u32;
3898
3899            let mut downloaded = 0u64;
3900
3901            for p in first_piece..=last_piece {
3902                if !chunk_tracker.has_piece(p) {
3903                    continue;
3904                }
3905
3906                let piece_start = lengths.piece_offset(p);
3907                let piece_end = piece_start + u64::from(lengths.piece_size(p));
3908
3909                // Clamp to file boundaries
3910                let overlap_start = piece_start.max(file_offset);
3911                let overlap_end = piece_end.min(file_end);
3912
3913                if overlap_start < overlap_end {
3914                    downloaded += overlap_end - overlap_start;
3915                }
3916            }
3917
3918            result.push(downloaded);
3919            file_offset = file_end;
3920        }
3921
3922        result
3923    }
3924
3925    /// Exponential backoff delay for V6 DHT retries (M97).
3926    /// 100ms, 200ms, 400ms, 800ms, 1600ms, 3200ms, 5000ms (cap).
3927    fn v6_retry_delay(&self) -> std::time::Duration {
3928        let base_ms: u64 = 100;
3929        let max_ms: u64 = 5000;
3930        let delay_ms = base_ms
3931            .saturating_mul(
3932                1u64.checked_shl(self.dht_v6_empty_count)
3933                    .unwrap_or(u64::MAX),
3934            )
3935            .min(max_ms);
3936        std::time::Duration::from_millis(delay_ms)
3937    }
3938
3939    /// Check if enough time has elapsed for the next V6 DHT retry (M97).
3940    fn should_retry_v6(&self) -> bool {
3941        let Some(last) = self.dht_v6_last_retry else {
3942            return true; // First attempt
3943        };
3944        last.elapsed() >= self.v6_retry_delay()
3945    }
3946
3947    /// Force an immediate DHT announce on all available DHT handles (v4 + v6).
3948    async fn handle_force_dht_announce(&self) {
3949        if let Some(dht) = self.current_dht()
3950            && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
3951        {
3952            warn!("Force DHT v4 announce failed: {e}");
3953        }
3954        if let Some(dht6) = self.current_dht_v6()
3955            && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
3956        {
3957            debug!("Force DHT v6 announce failed: {e}");
3958        }
3959        // Dual-swarm: also announce v2 hash for hybrid torrents
3960        if self.info_hashes.is_hybrid()
3961            && let Some(v2) = self.info_hashes.v2
3962        {
3963            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
3964            if v2_as_v1 != self.info_hash {
3965                if let Some(dht) = self.current_dht()
3966                    && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
3967                {
3968                    debug!("Force DHT v4 dual-swarm announce failed: {e}");
3969                }
3970                if let Some(dht6) = self.current_dht_v6()
3971                    && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
3972                {
3973                    debug!("Force DHT v6 dual-swarm announce failed: {e}");
3974                }
3975            }
3976        }
3977    }
3978
3979    /// M107: Periodic DHT re-query — discovers new peers during download.
3980    ///
3981    /// Replaces the old fixed 30s `dht_recheck_interval`. Clears the adder's
3982    /// seen set so previously-known peers can be re-evaluated, then issues
3983    /// fresh `get_peers` on all active DHT handles (v4, v6, v2-swarm).
3984    async fn run_dht_requery(&mut self) {
3985        if !self.config.enable_dht {
3986            return;
3987        }
3988
3989        // Guard: don't re-query if we already have plenty of known peers.
3990        // M133: Scale with config instead of hardcoded 500 — with max_peers=128
3991        // this becomes 512, close to the old value but adapts to custom limits.
3992        if self.peers.len() > self.config.max_peers.saturating_mul(4) {
3993            return;
3994        }
3995
3996        // M134: DhtLookup is now persistent — it re-injects routing table roots
3997        // every 15s internally. Only issue a fresh get_peers if the previous
3998        // lookup's channel has closed (lookup exhausted or aborted). Issuing a
3999        // new get_peers while one is active would abort the existing DhtLookup,
4000        // destroying its accumulated 256-node state.
4001
4002        // v4 DHT — only start if no active lookup
4003        if self.dht_peers_rx.is_none()
4004            && let Some(dht) = self.current_dht()
4005        {
4006            match dht.get_peers(self.info_hash).await {
4007                Ok(rx) => self.dht_peers_rx = Some(rx),
4008                Err(e) => warn!("DHT v4 re-query failed: {e}"),
4009            }
4010        }
4011
4012        // v6 DHT — only start if no active lookup
4013        if self.dht_v6_peers_rx.is_none()
4014            && self.dht_v6_empty_count < 30
4015            && self.should_retry_v6()
4016            && let Some(dht6) = self.current_dht_v6()
4017        {
4018            self.dht_v6_last_retry = Some(std::time::Instant::now());
4019            match dht6.get_peers(self.info_hash).await {
4020                Ok(rx) => self.dht_v6_peers_rx = Some(rx),
4021                Err(e) => debug!("DHT v6 re-query failed: {e}"),
4022            }
4023        }
4024
4025        // v2 swarm re-query for hybrid torrents — only start if no active lookup
4026        if self.info_hashes.is_hybrid()
4027            && let Some(v2) = self.info_hashes.v2
4028        {
4029            let v2_bytes: [u8; 20] = v2.0[..20]
4030                .try_into()
4031                .expect("Id32 is 32 bytes; first 20 always fit");
4032            let v2_as_v1 = Id20(v2_bytes);
4033
4034            if self.dht_v2_peers_rx.is_none()
4035                && let Some(dht) = self.current_dht()
4036            {
4037                match dht.get_peers(v2_as_v1).await {
4038                    Ok(rx) => self.dht_v2_peers_rx = Some(rx),
4039                    Err(e) => debug!("DHT v4 v2-swarm re-query failed: {e}"),
4040                }
4041            }
4042            if self.dht_v6_v2_peers_rx.is_none()
4043                && self.dht_v6_empty_count < 30
4044                && self.should_retry_v6()
4045                && let Some(dht6) = self.current_dht_v6()
4046            {
4047                self.dht_v6_last_retry = Some(std::time::Instant::now());
4048                match dht6.get_peers(v2_as_v1).await {
4049                    Ok(rx) => self.dht_v6_v2_peers_rx = Some(rx),
4050                    Err(e) => debug!("DHT v6 v2-swarm re-query failed: {e}"),
4051                }
4052            }
4053        }
4054
4055        debug!(peers = self.peers.len(), "DHT re-query triggered");
4056    }
4057
4058    /// Read a complete piece from disk by reading all chunks and concatenating.
4059    async fn handle_read_piece(&self, index: u32) -> crate::Result<Bytes> {
4060        let disk = self
4061            .disk
4062            .as_ref()
4063            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4064        let lengths = self
4065            .lengths
4066            .as_ref()
4067            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4068
4069        let piece_size = lengths.piece_size(index);
4070        if piece_size == 0 {
4071            return Err(crate::Error::InvalidPieceIndex {
4072                index,
4073                num_pieces: lengths.num_pieces(),
4074            });
4075        }
4076
4077        let chunk_size = lengths.chunk_size();
4078        let num_chunks = lengths.chunks_in_piece(index);
4079        let mut buf = bytes::BytesMut::with_capacity(piece_size as usize);
4080
4081        for chunk_idx in 0..num_chunks {
4082            let begin = chunk_idx * chunk_size;
4083            let len = if chunk_idx == num_chunks - 1 {
4084                piece_size - begin
4085            } else {
4086                chunk_size
4087            };
4088            let data = disk
4089                .read_chunk(index, begin, len, DiskJobFlags::empty())
4090                .await
4091                .map_err(crate::Error::Storage)?;
4092            buf.extend_from_slice(&data);
4093        }
4094
4095        Ok(buf.freeze())
4096    }
4097
4098    /// Flush the disk write cache.
4099    async fn handle_flush_cache(&self) -> crate::Result<()> {
4100        let disk = self
4101            .disk
4102            .as_ref()
4103            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4104        disk.flush_cache().await.map_err(crate::Error::Storage)
4105    }
4106
4107    /// Immediately initiate a connection to the given peer address.
4108    fn handle_connect_peer(&mut self, addr: SocketAddr) {
4109        // Skip if already connected
4110        if self.peers.contains_key(&addr) {
4111            return;
4112        }
4113        // M137: Track via unified PeerStates lifecycle
4114        if let Some(ref ps) = self.peer_states {
4115            ps.add_if_not_seen(addr, PeerSource::Incoming);
4116        }
4117    }
4118
4119    /// Fire `TrackerReply` / `TrackerError` alerts from announce outcomes.
4120    pub(crate) fn fire_tracker_alerts(&self, outcomes: &[crate::tracker_manager::TrackerOutcome]) {
4121        for outcome in outcomes {
4122            match &outcome.result {
4123                Ok(num_peers) => {
4124                    post_alert(
4125                        &self.alert_tx,
4126                        &self.alert_mask,
4127                        AlertKind::TrackerReply {
4128                            info_hash: self.info_hash,
4129                            url: outcome.url.clone(),
4130                            num_peers: *num_peers,
4131                        },
4132                    );
4133                }
4134                Err(msg) => {
4135                    post_alert(
4136                        &self.alert_tx,
4137                        &self.alert_mask,
4138                        AlertKind::TrackerError {
4139                            info_hash: self.info_hash,
4140                            url: outcome.url.clone(),
4141                            message: msg.clone(),
4142                        },
4143                    );
4144                }
4145            }
4146        }
4147    }
4148
4149    /// Calculate bytes remaining for tracker announce.
4150    pub(crate) fn calculate_left(&self) -> u64 {
4151        match (&self.meta, &self.chunk_tracker) {
4152            (Some(meta), Some(ct)) => {
4153                let last_piece_have = self.num_pieces > 0 && ct.bitfield().get(self.num_pieces - 1);
4154                compute_bytes_left(
4155                    meta.info.total_length(),
4156                    meta.info.piece_length,
4157                    u64::from(self.num_pieces),
4158                    u64::from(ct.bitfield().count_ones()),
4159                    last_piece_have,
4160                )
4161            }
4162            _ => 0,
4163        }
4164    }
4165
4166    pub(crate) async fn shutdown_peers(&mut self) {
4167        // Best-effort announce Stopped to trackers (with timeout to prevent hang)
4168        let left = self.calculate_left();
4169        let _ = tokio::time::timeout(
4170            std::time::Duration::from_secs(3),
4171            self.tracker_manager
4172                .announce_stopped(self.uploaded, self.downloaded, left),
4173        )
4174        .await;
4175
4176        // Non-blocking peer shutdown — peers may already be dead or channels full
4177        for peer in self.peers.values() {
4178            let _ = peer.cmd_tx.try_send(PeerCommand::Shutdown);
4179        }
4180    }
4181
4182    // ----- Event handlers -----
4183
4184    pub(crate) async fn handle_piece_data(
4185        &mut self,
4186        peer_addr: SocketAddr,
4187        index: u32,
4188        begin: u32,
4189        data: Bytes,
4190    ) {
4191        // Skip duplicate blocks — in end-game mode or after timeout re-requests,
4192        // the same block may arrive from multiple peers. Writing it to the store
4193        // buffer would overwrite valid data that's pending verification.
4194        if let Some(ref ct) = self.chunk_tracker
4195            && ct.has_chunk(index, begin)
4196        {
4197            self.total_download += data.len() as u64 + 13;
4198            // Remove from pending_requests to free pipeline slots. Without this,
4199            // the peer accumulates phantom entries from already-verified pieces
4200            // and eventually has zero available pipeline slots — permanent stall.
4201            if let Some(peer) = self.peers.get_mut(&peer_addr) {
4202                peer.pending_requests.remove(index, begin);
4203            }
4204            // Remove from end-game tracker so pick_block won't return this
4205            // block again. The normal path calls block_received which does
4206            // this, but we skip that path for duplicates.
4207            if self.end_game.is_active() {
4208                self.end_game.block_received(index, begin, peer_addr);
4209            }
4210            // M75: Permit already returned by peer task on Piece receipt
4211            return;
4212        }
4213
4214        let data_len = data.len();
4215
4216        // M100: Deferred write via per-torrent writer task.
4217        if let Some(ref disk) = self.disk {
4218            disk.write_block_deferred(index, begin, data);
4219        }
4220
4221        self.downloaded += data_len as u64;
4222        self.total_download += data_len as u64 + 13; // payload + message header
4223        self.last_download = now_unix();
4224        self.need_save_resume = true;
4225
4226        // M93: Track piece ownership (actor learns about peer's CAS reservation via chunk arrival)
4227        if let Some(slab_idx) = self.peer_slab.slot_of(&peer_addr)
4228            && self.piece_owner.get(index as usize) == Some(&None)
4229        {
4230            self.piece_owner[index as usize] = Some(slab_idx);
4231            // M149: Track when piece started downloading
4232            if self.inflight_started.get(index as usize) == Some(&None) {
4233                self.inflight_started[index as usize] = Some(Instant::now());
4234            }
4235            // M103: Add to steal queue if piece has unrequested blocks
4236            if let (Some(sc), Some(bm)) = (&self.steal_candidates, &self.block_maps)
4237                && let Some(lengths) = &self.lengths
4238            {
4239                let total_blocks = lengths.chunks_in_piece(index);
4240                if bm.next_unrequested(index, total_blocks).is_some() {
4241                    sc.push(index);
4242                }
4243            }
4244        }
4245
4246        // Smart banning: track which peers contribute to each piece
4247        self.piece_contributors
4248            .entry(index)
4249            .or_default()
4250            .insert(peer_addr.ip());
4251
4252        let now = std::time::Instant::now();
4253        if let Some(peer) = self.peers.get_mut(&peer_addr) {
4254            peer.pending_requests.remove(index, begin);
4255            peer.download_bytes_window += data_len as u64;
4256            peer.download_bytes_total += data_len as u64;
4257            peer.pipeline.block_received(data_len as u32);
4258            peer.last_data_received = Some(now);
4259            // Clear snub if snubbed
4260            if peer.snubbed {
4261                peer.snubbed = false;
4262            }
4263        }
4264        // M137: Backoff is now automatically reset by mark_live() in PeerStates.
4265
4266        // End-game: cancel this block on all other peers. The 200ms end-game
4267        // refill tick will re-stock freed peers — no reactive cascade needed.
4268        if self.end_game.is_active() {
4269            let cancels = self.end_game.block_received(index, begin, peer_addr);
4270            for (cancel_addr, ci, cb, cl) in cancels {
4271                if let Some(cancel_peer) = self.peers.get_mut(&cancel_addr) {
4272                    let _ = cancel_peer.cmd_tx.try_send(PeerCommand::Cancel {
4273                        index: ci,
4274                        begin: cb,
4275                        length: cl,
4276                    });
4277                    cancel_peer.pending_requests.remove(ci, cb);
4278                }
4279            }
4280        }
4281
4282        // Track chunk completion
4283        let piece_complete = if let Some(ref mut ct) = self.chunk_tracker {
4284            ct.chunk_received(index, begin)
4285        } else {
4286            false
4287        };
4288
4289        if piece_complete && !self.pending_verify.contains(&index) {
4290            // M44/M118: Predictive piece announce — broadcast Have before verification
4291            if self.config.predictive_piece_announce_ms > 0
4292                && !self.predictive_have_sent.contains(&index)
4293            {
4294                self.predictive_have_sent.insert(index);
4295                let _ = self.have_broadcast_tx.send(index);
4296            }
4297
4298            // M100: Flush deferred writes before verification — ensures all
4299            // blocks are on disk so read_piece() sees complete data.
4300            if let Some(ref disk) = self.disk {
4301                disk.flush_piece_writes(index).await;
4302            }
4303
4304            match self.version {
4305                irontide_core::TorrentVersion::V1Only => {
4306                    // Async: fire-and-forget, result via verify_result_rx
4307                    if let Some(ref disk) = self.disk
4308                        && let Some(expected) = self
4309                            .meta
4310                            .as_ref()
4311                            .and_then(|m| m.info.piece_hash(index as usize))
4312                    {
4313                        self.pending_verify.insert(index);
4314                        let generation = self
4315                            .piece_generations
4316                            .get(index as usize)
4317                            .copied()
4318                            .unwrap_or(0);
4319                        disk.enqueue_verify(index, expected, generation, &self.verify_result_tx);
4320                    }
4321                }
4322                irontide_core::TorrentVersion::V2Only => {
4323                    // Blocking: needs mutable hash_picker for Merkle tree
4324                    self.verify_and_mark_piece_v2(index).await;
4325                }
4326                irontide_core::TorrentVersion::Hybrid => {
4327                    // Blocking: needs both v1+v2 decision matrix
4328                    self.verify_and_mark_piece_hybrid(index).await;
4329                }
4330            }
4331        }
4332
4333        // M75: Permit already returned by peer task on Piece receipt.
4334        // End-game dispatch still happens here.
4335        if self.end_game.is_active() {
4336            self.request_end_game_block(peer_addr).await;
4337        }
4338    }
4339
4340    /// M92: Process a batch of block completions from a single peer.
4341    /// Iterates blocks, calling `process_block_completion()` for each.
4342    /// Piece verifications are triggered inline as pieces complete
4343    /// (same as the former per-block path).
4344    pub(crate) async fn handle_piece_blocks_batch(
4345        &mut self,
4346        peer_addr: SocketAddr,
4347        blocks: Vec<crate::types::BlockEntry>,
4348    ) {
4349        for block in &blocks {
4350            self.process_block_completion(
4351                peer_addr,
4352                block.index,
4353                block.begin,
4354                block.length,
4355                block.rtt,
4356            )
4357            .await;
4358        }
4359    }
4360
4361    fn handle_open_file(
4362        &mut self,
4363        file_index: usize,
4364    ) -> crate::Result<crate::streaming::FileStreamHandle> {
4365        let meta = self
4366            .meta
4367            .as_ref()
4368            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4369        let files = meta.info.files();
4370        if file_index >= files.len() {
4371            return Err(crate::Error::InvalidFileIndex {
4372                index: file_index,
4373                count: files.len(),
4374            });
4375        }
4376        if self.file_priorities.get(file_index).copied() == Some(FilePriority::Skip) {
4377            return Err(crate::Error::FileSkipped { index: file_index });
4378        }
4379
4380        let lengths = self
4381            .lengths
4382            .as_ref()
4383            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4384        let disk = self
4385            .disk
4386            .as_ref()
4387            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4388
4389        // Compute file offset within torrent data
4390        let mut file_offset = 0u64;
4391        for f in &files[..file_index] {
4392            file_offset += f.length;
4393        }
4394        let file_length = files[file_index].length;
4395
4396        let (cursor_tx, cursor_rx) = tokio::sync::watch::channel(0u64);
4397
4398        let permit = self
4399            .stream_read_semaphore
4400            .clone()
4401            .try_acquire_owned()
4402            .map_err(|_| crate::Error::Connection("too many concurrent stream readers".into()))?;
4403
4404        // Add streaming cursor for the actor to track
4405        self.streaming_cursors
4406            .push(crate::streaming::StreamingCursor {
4407                file_index,
4408                file_offset,
4409                cursor_piece: (file_offset / lengths.piece_length()) as u32,
4410                readahead_pieces: self.config.readahead_pieces,
4411                cursor_rx,
4412            });
4413
4414        Ok(crate::streaming::FileStreamHandle {
4415            disk: disk.clone(),
4416            lengths: lengths.clone(),
4417            file_index,
4418            file_offset,
4419            file_length,
4420            cursor_tx,
4421            piece_ready_rx: self.piece_ready_tx.subscribe(),
4422            have: self.have_watch_rx.clone(),
4423            read_permit: permit,
4424        })
4425    }
4426
4427    /// M44: Suggest cached pieces to connected peers (BEP 6).
4428    async fn suggest_cached_pieces(&mut self) {
4429        if !self.config.suggest_mode {
4430            return;
4431        }
4432        let disk = match self.disk {
4433            Some(ref d) => d.clone(),
4434            None => return,
4435        };
4436        let cached = disk.cached_pieces().await;
4437        if cached.is_empty() {
4438            return;
4439        }
4440        let max_suggest = self.config.max_suggest_pieces;
4441        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4442        for peer_addr in peer_addrs {
4443            let already_suggested = self.suggested_to_peers.entry(peer_addr).or_default();
4444            let peer_has_piece = |piece: u32| -> bool {
4445                self.peers
4446                    .get(&peer_addr)
4447                    .is_some_and(|p| p.bitfield.get(piece))
4448            };
4449            let mut sent = 0;
4450            for &piece in &cached {
4451                if sent >= max_suggest {
4452                    break;
4453                }
4454                if peer_has_piece(piece) {
4455                    continue;
4456                }
4457                if already_suggested.contains(&piece) {
4458                    continue;
4459                }
4460                if let Some(peer) = self.peers.get(&peer_addr) {
4461                    let _ = peer.cmd_tx.try_send(PeerCommand::SuggestPiece(piece));
4462                    already_suggested.insert(piece);
4463                    sent += 1;
4464                }
4465            }
4466        }
4467    }
4468
4469    /// M147: Handle pre-resolved metadata from the background resolver.
4470    ///
4471    /// If the `TorrentActor` is still in `FetchingMetadata` state, feed the
4472    /// info bytes through `MetadataDownloader` and call `try_assemble_metadata()`.
4473    /// If already past that state (actor resolved first), silently ignore.
4474    async fn handle_pre_resolved_metadata(&mut self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
4475        // Only act if still fetching metadata — actor may have resolved first.
4476        if self.state != TorrentState::FetchingMetadata {
4477            debug!(
4478                info_hash = %self.info_hash,
4479                state = ?self.state,
4480                "ignoring pre-resolved metadata: already past FetchingMetadata"
4481            );
4482            return;
4483        }
4484
4485        debug!(
4486            info_hash = %self.info_hash,
4487            info_bytes_len = info_bytes.len(),
4488            num_peers = peers.len(),
4489            "received pre-resolved metadata from background resolver"
4490        );
4491
4492        // Feed the complete info bytes to the MetadataDownloader.
4493        if let Some(ref mut dl) = self.metadata_downloader {
4494            // Set total size so the downloader knows the expected piece count.
4495            dl.set_total_size(info_bytes.len() as u64);
4496
4497            // Feed as a single piece (piece 0) containing the full info dict.
4498            // For metadata smaller than 16 KiB this is a single piece.
4499            // For larger metadata, feed each 16 KiB chunk as a separate piece.
4500            let piece_size: usize = 16384;
4501            let num_pieces = info_bytes.len().div_ceil(piece_size);
4502            for i in 0..num_pieces {
4503                let start = i * piece_size;
4504                let end = (start + piece_size).min(info_bytes.len());
4505                let data = bytes::Bytes::copy_from_slice(&info_bytes[start..end]);
4506                dl.piece_received(i as u32, data);
4507            }
4508        }
4509
4510        // Attempt assembly — this will transition to Downloading if
4511        // the info_hash validates.
4512        self.try_assemble_metadata().await;
4513
4514        // Pre-seed discovered peers into the pipeline.
4515        if !peers.is_empty() {
4516            self.handle_add_peers(peers, crate::peer_state::PeerSource::Dht);
4517        }
4518    }
4519
4520    pub(crate) async fn try_assemble_metadata(&mut self) {
4521        let assembled = if let Some(ref dl) = self.metadata_downloader {
4522            dl.assemble_and_verify()
4523        } else {
4524            return;
4525        };
4526
4527        match assembled {
4528            Ok(info_bytes) => {
4529                // Build torrent bytes wrapping the raw info dict into a minimal torrent
4530                // We need to parse it as a full torrent. The info_bytes is the raw bencoded
4531                // info dict. We'll build a minimal torrent around it.
4532                // Actually, torrent_from_bytes expects a full torrent dict.
4533                // Let's build one:
4534                let mut torrent_bytes = b"d4:info".to_vec();
4535                torrent_bytes.extend_from_slice(&info_bytes);
4536                torrent_bytes.push(b'e');
4537
4538                match torrent_from_bytes(&torrent_bytes) {
4539                    Ok(meta) => {
4540                        let num_pieces = meta.info.num_pieces() as u32;
4541                        let lengths = Lengths::new(
4542                            meta.info.total_length(),
4543                            meta.info.piece_length,
4544                            DEFAULT_CHUNK_SIZE,
4545                        );
4546
4547                        // Create filesystem storage now that we know the file
4548                        // layout. M252/ER5: lay the logical list out per the
4549                        // torrent's `content_layout` first — these paths land
4550                        // on disk.
4551                        let files = self.config.content_layout.apply_to_files(meta.info.files());
4552                        let file_paths: Vec<std::path::PathBuf> = files
4553                            .iter()
4554                            .map(|f| f.path.iter().collect::<std::path::PathBuf>())
4555                            .collect();
4556                        let file_lengths_vec: Vec<u64> = files.iter().map(|f| f.length).collect();
4557                        let prealloc_mode = self.config.preallocate_mode;
4558                        let storage: Arc<dyn TorrentStorage> =
4559                            match irontide_storage::FilesystemStorage::new(
4560                                &self.config.download_dir,
4561                                file_paths,
4562                                file_lengths_vec,
4563                                lengths.clone(),
4564                                None,
4565                                prealloc_mode,
4566                                self.config.filesystem_direct_io,
4567                            ) {
4568                                Ok(s) => Arc::new(s),
4569                                Err(e) => {
4570                                    warn!(
4571                                        "failed to create filesystem storage: {e}, falling back to memory"
4572                                    );
4573                                    Arc::new(MemoryStorage::new(lengths.clone()))
4574                                }
4575                            };
4576                        let mut disk_handle = self
4577                            .disk_manager
4578                            .register_torrent(self.info_hash, storage)
4579                            .await;
4580
4581                        self.chunk_tracker = Some(ChunkTracker::new(lengths.clone()));
4582                        self.lengths = Some(lengths);
4583                        self.num_pieces = num_pieces;
4584                        // M96: Initialize real generation counters + hash result channel
4585                        self.piece_generations = vec![0u64; num_pieces as usize];
4586                        let (hash_tx, hash_rx) = tokio::sync::mpsc::channel(64);
4587                        self.hash_result_tx = hash_tx;
4588                        self.hash_result_rx = hash_rx;
4589                        // M96: Wire hash pool into disk handle (version check deferred
4590                        // until after metadata detection below sets self.version)
4591                        if let Some(ref pool) = self.hash_pool_ref {
4592                            disk_handle.set_hash_pool(pool.clone());
4593                            disk_handle.set_hash_result_tx(self.hash_result_tx.clone());
4594                        }
4595                        self.disk = Some(disk_handle);
4596                        // Update all connected peer tasks so they can validate
4597                        // incoming Bitfield messages with the correct piece count.
4598                        for peer in self.peers.values() {
4599                            let _ = peer
4600                                .cmd_tx
4601                                .try_send(PeerCommand::UpdateNumPieces(num_pieces));
4602                        }
4603                        let file_lengths: Vec<u64> =
4604                            meta.info.files().iter().map(|f| f.length).collect();
4605                        let mut meta = meta;
4606                        meta.info_bytes = Some(Bytes::from(info_bytes));
4607                        self.meta = Some(meta);
4608
4609                        // M116: Populate cached file info for zero-alloc completion checks.
4610                        if let (Some(meta), Some(lengths)) = (&self.meta, &self.lengths) {
4611                            self.cached_files = Some(build_cached_file_info(meta, lengths));
4612                        }
4613
4614                        self.file_priorities = vec![FilePriority::Normal; file_lengths.len()];
4615
4616                        // M254: explicit at-add priorities win (dialog-state-
4617                        // wins, qBt semantics); BEP 53 so= applies only when
4618                        // the user supplied none.
4619                        if !self.config.file_priorities.is_empty() {
4620                            self.file_priorities
4621                                .clone_from(&self.config.file_priorities);
4622                            self.file_priorities
4623                                .resize(file_lengths.len(), FilePriority::Normal);
4624                            self.magnet_selected_files = None;
4625                        } else if let Some(ref selections) = self.magnet_selected_files {
4626                            // BEP 53: apply magnet so= file selection
4627                            self.file_priorities = irontide_core::FileSelection::to_priorities(
4628                                selections,
4629                                file_lengths.len(),
4630                            );
4631                            self.magnet_selected_files = None;
4632                        }
4633
4634                        self.wanted_pieces = crate::piece_selector::build_wanted_pieces(
4635                            &self.file_priorities,
4636                            &file_lengths,
4637                            self.lengths.as_ref().unwrap(),
4638                        );
4639                        if self.config.share_mode {
4640                            self.transition_state(TorrentState::Sharing);
4641                        } else {
4642                            self.transition_state(TorrentState::Downloading);
4643                        }
4644                        self.metadata_downloader = None;
4645
4646                        // Populate tracker manager with newly parsed metadata
4647                        if let Some(ref meta) = self.meta {
4648                            self.tracker_manager
4649                                .set_metadata_filtered(meta, self.config.url_security);
4650                        }
4651
4652                        // Detect hybrid/v2 from metadata and update dual-swarm state
4653                        // (Gap 1 & 2: propagate info_hashes to tracker + DHT after magnet resolves)
4654                        if let Ok(detected) = irontide_core::torrent_from_bytes_any(&torrent_bytes)
4655                        {
4656                            let new_version = detected.version();
4657                            if new_version != irontide_core::TorrentVersion::V1Only {
4658                                let new_hashes = detected.info_hashes();
4659                                self.version = new_version;
4660                                self.info_hashes = new_hashes.clone();
4661                                self.tracker_manager.set_info_hashes(new_hashes.clone());
4662                                if let Some(v2_meta) = detected.as_v2() {
4663                                    self.meta_v2 = Some(v2_meta.clone());
4664                                }
4665                                // Start v2 DHT lookups for hybrid torrents
4666                                if new_hashes.is_hybrid()
4667                                    && let Some(v2) = new_hashes.v2
4668                                {
4669                                    let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
4670                                    if v2_as_v1 != self.info_hash {
4671                                        if self.dht_v2_peers_rx.is_none()
4672                                            && let Some(dht) = self.current_dht()
4673                                            && let Ok(rx) = dht.get_peers(v2_as_v1).await
4674                                        {
4675                                            self.dht_v2_peers_rx = Some(rx);
4676                                        }
4677                                        if self.dht_v6_v2_peers_rx.is_none()
4678                                            && self.dht_v6_empty_count < 30
4679                                            && self.should_retry_v6()
4680                                            && let Some(dht6) = self.current_dht_v6()
4681                                            && let Ok(rx) = dht6.get_peers(v2_as_v1).await
4682                                        {
4683                                            self.dht_v6_last_retry =
4684                                                Some(std::time::Instant::now());
4685                                            self.dht_v6_v2_peers_rx = Some(rx);
4686                                        }
4687                                    }
4688                                }
4689                            }
4690                        }
4691
4692                        let name = self
4693                            .meta
4694                            .as_ref()
4695                            .map(|m| m.info.name.clone())
4696                            .unwrap_or_default();
4697                        post_alert(
4698                            &self.alert_tx,
4699                            &self.alert_mask,
4700                            AlertKind::MetadataReceived {
4701                                info_hash: self.info_hash,
4702                                name,
4703                            },
4704                        );
4705                        info!("metadata assembled, switching to Downloading");
4706
4707                        // M93: Initialize lock-free piece states after metadata
4708                        if let Some(ct) = &self.chunk_tracker {
4709                            let atomic_states = Arc::new(AtomicPieceStates::new(
4710                                self.num_pieces,
4711                                ct.bitfield(),
4712                                &self.wanted_pieces,
4713                            ));
4714                            self.atomic_states = Some(Arc::clone(&atomic_states));
4715                            self.piece_owner = vec![None; self.num_pieces as usize];
4716                            // M149: Initialize inflight tracking
4717                            self.inflight_started = vec![None; self.num_pieces as usize];
4718                            self.max_in_flight = self.config.max_in_flight_pieces;
4719
4720                            // M103: Initialize block stealing infrastructure
4721                            if self.config.use_block_stealing {
4722                                if let Some(ref lengths) = self.lengths {
4723                                    self.block_maps =
4724                                        Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
4725                                }
4726                                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
4727                            }
4728                            // M120: Per-piece write guards
4729                            self.piece_write_guards = Some(Arc::new(
4730                                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
4731                            ));
4732
4733                            // M187: Init direct-acquire dispatch state.
4734                            self.piece_tracker = Some(PieceTracker::new(
4735                                self.num_pieces,
4736                                ct.bitfield(),
4737                                &self.wanted_pieces,
4738                            ));
4739                            if let Some(ref cached) = self.cached_files {
4740                                let file_piece_ranges: Vec<(u32, u32)> = cached
4741                                    .entries
4742                                    .iter()
4743                                    .map(|e| (e.first_piece, e.last_piece))
4744                                    .collect();
4745                                let om = Arc::new(PieceOrderMap::build(
4746                                    &self.file_priorities,
4747                                    &file_piece_ranges,
4748                                    self.num_pieces,
4749                                    0,
4750                                    self.piece_ordering(),
4751                                ));
4752                                self.order_map_tx.send_replace(om);
4753                            }
4754
4755                            let notify = Arc::new(tokio::sync::Notify::new());
4756                            self.reservation_notify = Some(notify);
4757                        }
4758
4759                        // Start web seeds now that we have metadata
4760                        self.spawn_web_seeds();
4761                        self.assign_pieces_to_web_seeds();
4762
4763                        // Kick-start piece requesting for all peers that connected during
4764                        // metadata phase. Send StartRequesting to all connected peers.
4765                        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4766                        info!(
4767                            connected_peers = peer_addrs.len(),
4768                            "kick-starting piece requests for pre-connected peers"
4769                        );
4770                        for addr in peer_addrs {
4771                            let has_bitfield =
4772                                self.peers.get(&addr).map_or(0, |p| p.bitfield.count_ones());
4773                            let is_choking = self.peers.get(&addr).is_none_or(|p| p.peer_choking);
4774                            debug!(%addr, has_bitfield, is_choking, "post-metadata peer state");
4775                            self.maybe_express_interest(addr).await;
4776                            if let Some(peer) = self.peers.get(&addr)
4777                                && peer.bitfield.count_ones() > 0
4778                            {
4779                                let _slot = self.peer_slab.insert(addr);
4780                            }
4781                        }
4782                        self.recalc_max_in_flight();
4783                        // M93: Inform all connected peers about lock-free dispatch state.
4784                        // M159: Skip while user seed mode is active — we are currently
4785                        // not scheduling any new block requests.
4786                        if !self.user_seed_mode
4787                            && let Some(notify) = &self.reservation_notify
4788                            && let Some(ref lengths) = self.lengths
4789                        {
4790                            for peer in self.peers.values() {
4791                                let _ = peer.cmd_tx.try_send(PeerCommand::StartRequesting {
4792                                    piece_notify: Arc::clone(notify),
4793                                    disk_handle: self.disk.clone(),
4794                                    write_error_tx: self.write_error_tx.clone(),
4795                                    lengths: lengths.clone(),
4796                                });
4797                            }
4798                        }
4799                    }
4800                    Err(e) => {
4801                        warn!("failed to parse assembled metadata: {e}");
4802                        post_alert(
4803                            &self.alert_tx,
4804                            &self.alert_mask,
4805                            AlertKind::MetadataFailed {
4806                                info_hash: self.info_hash,
4807                            },
4808                        );
4809                    }
4810                }
4811            }
4812            Err(e) => {
4813                warn!("metadata assembly failed: {e}");
4814                post_alert(
4815                    &self.alert_tx,
4816                    &self.alert_mask,
4817                    AlertKind::MetadataFailed {
4818                        info_hash: self.info_hash,
4819                    },
4820                );
4821            }
4822        }
4823    }
4824
4825    // ----- Web seeding (M22) -----
4826
4827    fn spawn_web_seeds(&mut self) {
4828        if !self.config.enable_web_seed {
4829            return;
4830        }
4831        let Some(meta) = &self.meta else { return };
4832        let lengths = match &self.lengths {
4833            Some(l) => l.clone(),
4834            None => return,
4835        };
4836
4837        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
4838        let file_map = irontide_storage::FileMap::new(file_lengths, lengths.clone());
4839
4840        // BEP 19 (GetRight) web seeds
4841        for url in &meta.url_list {
4842            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4843                continue;
4844            }
4845            if self.web_seeds.len() >= self.config.max_web_seeds {
4846                break;
4847            }
4848
4849            // Security validation
4850            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4851                warn!(%url, %e, "web seed URL rejected by security policy");
4852                continue;
4853            }
4854
4855            let url_builder = if meta.info.length.is_some() {
4856                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone())
4857            } else {
4858                let file_paths: Vec<String> = meta
4859                    .info
4860                    .files()
4861                    .iter()
4862                    .map(|f| f.path[1..].join("/")) // skip torrent name prefix
4863                    .collect();
4864                crate::web_seed::WebSeedUrlBuilder::multi(
4865                    url.clone(),
4866                    meta.info.name.clone(),
4867                    file_paths,
4868                )
4869            };
4870
4871            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4872            let initial_downloaded = self
4873                .web_seed_stats
4874                .get(url)
4875                .map_or(0, |s| s.downloaded_bytes);
4876            let task = crate::web_seed::WebSeedTask::new(
4877                url.clone(),
4878                crate::web_seed::WebSeedMode::GetRight,
4879                url_builder,
4880                lengths.clone(),
4881                file_map.clone(),
4882                self.info_hash,
4883                cmd_rx,
4884                self.event_tx.clone(),
4885                self.config.url_security,
4886                self.config.web_seed_progress_throttle_ms,
4887                initial_downloaded,
4888                self.config.web_seed_retry_base_secs,
4889                self.config.web_seed_retry_factor,
4890                self.config.web_seed_retry_cap_secs,
4891                self.config.web_seed_max_failures,
4892            );
4893            tokio::spawn(task.run());
4894            self.web_seeds.insert(url.clone(), cmd_tx);
4895            debug!(url, "spawned BEP 19 web seed");
4896        }
4897
4898        // BEP 17 (Hoffman) HTTP seeds
4899        for url in &meta.httpseeds {
4900            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4901                continue;
4902            }
4903            if self.web_seeds.len() >= self.config.max_web_seeds {
4904                break;
4905            }
4906
4907            // Security validation
4908            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4909                warn!(%url, %e, "web seed URL rejected by security policy");
4910                continue;
4911            }
4912
4913            // BEP 17 doesn't use URL builder for per-file paths; it sends parameterized URLs
4914            let url_builder =
4915                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone());
4916
4917            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4918            let initial_downloaded = self
4919                .web_seed_stats
4920                .get(url)
4921                .map_or(0, |s| s.downloaded_bytes);
4922            let task = crate::web_seed::WebSeedTask::new(
4923                url.clone(),
4924                crate::web_seed::WebSeedMode::Hoffman,
4925                url_builder,
4926                lengths.clone(),
4927                file_map.clone(),
4928                self.info_hash,
4929                cmd_rx,
4930                self.event_tx.clone(),
4931                self.config.url_security,
4932                self.config.web_seed_progress_throttle_ms,
4933                initial_downloaded,
4934                self.config.web_seed_retry_base_secs,
4935                self.config.web_seed_retry_factor,
4936                self.config.web_seed_retry_cap_secs,
4937                self.config.web_seed_max_failures,
4938            );
4939            tokio::spawn(task.run());
4940            self.web_seeds.insert(url.clone(), cmd_tx);
4941            debug!(url, "spawned BEP 17 web seed");
4942        }
4943    }
4944
4945    pub(crate) fn assign_pieces_to_web_seeds(&mut self) {
4946        if self.state != TorrentState::Downloading || self.end_game.is_active() {
4947            return;
4948        }
4949
4950        // Collect idle web seed URLs (not currently downloading a piece)
4951        let active_urls: HashSet<&String> = self.web_seed_in_flight.values().collect();
4952        let idle_urls: Vec<String> = self
4953            .web_seeds
4954            .keys()
4955            .filter(|u| !active_urls.contains(u))
4956            .cloned()
4957            .collect();
4958
4959        let Some(ct) = &self.chunk_tracker else {
4960            return;
4961        };
4962
4963        for url in idle_urls {
4964            // Find lowest-index piece that is: not verified, not reserved by a peer,
4965            // not in web_seed_in_flight, and wanted.
4966            let piece = (0..self.num_pieces).find(|&i| {
4967                !ct.has_piece(i)
4968                    && !self
4969                        .piece_owner
4970                        .get(i as usize)
4971                        .is_some_and(std::option::Option::is_some)
4972                    && !self.web_seed_in_flight.contains_key(&i)
4973                    && self.wanted_pieces.get(i)
4974            });
4975
4976            if let Some(piece) = piece
4977                && let Some(cmd_tx) = self.web_seeds.get(&url)
4978            {
4979                let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::FetchPiece(piece));
4980                self.web_seed_in_flight.insert(piece, url);
4981            }
4982        }
4983    }
4984
4985    pub(crate) async fn handle_web_seed_piece_data(
4986        &mut self,
4987        url: String,
4988        index: u32,
4989        data: Bytes,
4990    ) {
4991        self.web_seed_in_flight.remove(&index);
4992
4993        // If peer already completed this piece, discard
4994        if let Some(ref ct) = self.chunk_tracker
4995            && ct.has_piece(index)
4996        {
4997            self.assign_pieces_to_web_seeds();
4998            return;
4999        }
5000
5001        // Write entire piece to disk at offset 0
5002        if let Some(ref disk) = self.disk
5003            && let Err(e) = disk
5004                .write_chunk(index, 0, data.clone(), DiskJobFlags::FLUSH_PIECE)
5005                .await
5006        {
5007            warn!(index, "web seed: failed to write piece: {e}");
5008            self.assign_pieces_to_web_seeds();
5009            return;
5010        }
5011
5012        // Mark all chunks as received
5013        if let Some(ref mut ct) = self.chunk_tracker
5014            && let Some(ref lengths) = self.lengths
5015        {
5016            let num_chunks = lengths.chunks_in_piece(index);
5017            for chunk_idx in 0..num_chunks {
5018                if let Some((begin, _len)) = lengths.chunk_info(index, chunk_idx) {
5019                    ct.chunk_received(index, begin);
5020                }
5021            }
5022        }
5023
5024        self.downloaded += data.len() as u64;
5025        self.total_download += data.len() as u64 + 13; // payload + message header
5026        self.last_download = now_unix();
5027        self.need_save_resume = true;
5028
5029        // Verify the piece hash
5030        self.verify_and_mark_piece(index).await;
5031
5032        // If hash failed, ban this web seed (BEP 19 spec)
5033        if let Some(ref ct) = self.chunk_tracker
5034            && !ct.has_piece(index)
5035        {
5036            self.ban_web_seed(&url);
5037            return;
5038        }
5039
5040        self.assign_pieces_to_web_seeds();
5041    }
5042
5043    pub(crate) fn handle_web_seed_error(&mut self, url: &str, piece: u32, message: &str) {
5044        self.web_seed_in_flight.remove(&piece);
5045        warn!(%url, piece, %message, "web seed error");
5046        self.assign_pieces_to_web_seeds();
5047    }
5048
5049    /// M178: Update per-URL `WebSeedStats` from a `WebSeedProgress` event.
5050    ///
5051    /// State machine: Idle → Active on first success; Active → Errored on
5052    /// failure; Errored → Active on recovery (`last_error` PERSISTS through
5053    /// recovery per Issue 2.2 / D-eng-8). `consecutive_failures` increments
5054    /// monotonically within a failure run and resets to zero on success.
5055    /// `last_attempt_unix_secs` updates on every event regardless of outcome.
5056    pub(crate) fn handle_web_seed_progress(
5057        &mut self,
5058        url: &str,
5059        bytes: u64,
5060        rate_bps: u64,
5061        error: Option<String>,
5062    ) {
5063        let now_unix = std::time::SystemTime::now()
5064            .duration_since(std::time::UNIX_EPOCH)
5065            .map_or(0, |d| d.as_secs());
5066        let entry = self
5067            .web_seed_stats
5068            .entry(url.to_owned())
5069            .or_insert_with(|| irontide_core::WebSeedStats {
5070                url: url.to_owned(),
5071                ..Default::default()
5072            });
5073        entry.downloaded_bytes = bytes;
5074        entry.last_rate_bps = rate_bps;
5075        entry.last_attempt_unix_secs = now_unix;
5076        if let Some(msg) = error {
5077            entry.state = irontide_core::WebSeedState::Errored;
5078            entry.last_error = Some(msg);
5079            entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
5080            // M186: Populate next_retry_unix_secs during backoff
5081            let attempt = entry.consecutive_failures.saturating_sub(1);
5082            let secs = self
5083                .config
5084                .web_seed_retry_base_secs
5085                .saturating_mul(self.config.web_seed_retry_factor.saturating_pow(attempt))
5086                .min(self.config.web_seed_retry_cap_secs);
5087            entry.next_retry_unix_secs = Some(now_unix + secs);
5088        } else {
5089            entry.state = irontide_core::WebSeedState::Active;
5090            entry.consecutive_failures = 0;
5091            entry.next_retry_unix_secs = None;
5092        }
5093        self.need_save_resume = true;
5094    }
5095
5096    pub(crate) fn ban_web_seed(&mut self, url: &str) {
5097        warn!(%url, "banning web seed due to hash failure");
5098        self.banned_web_seeds.insert(url.to_owned());
5099
5100        // Send shutdown to the task
5101        if let Some(cmd_tx) = self.web_seeds.remove(url) {
5102            let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::Shutdown);
5103        }
5104
5105        // Remove all in-flight pieces for this URL
5106        self.web_seed_in_flight.retain(|_, v| v != url);
5107
5108        post_alert(
5109            &self.alert_tx,
5110            &self.alert_mask,
5111            AlertKind::WebSeedBanned {
5112                info_hash: self.info_hash,
5113                url: url.to_owned(),
5114            },
5115        );
5116    }
5117
5118    async fn shutdown_web_seeds(&mut self) {
5119        for (_, cmd_tx) in self.web_seeds.drain() {
5120            let _ = cmd_tx.send(crate::web_seed::WebSeedCommand::Shutdown).await;
5121        }
5122        self.web_seed_in_flight.clear();
5123    }
5124
5125    /// Rebuild the cached peer rates map from current peer state.
5126    fn refresh_peer_rates(&mut self) {
5127        self.cached_peer_rates.clear();
5128        self.cached_peer_rates.reserve(self.peers.len());
5129        for (&addr, p) in &self.peers {
5130            self.cached_peer_rates.insert(addr, p.pipeline.ewma_rate());
5131        }
5132    }
5133
5134    /// M257c: allocate the per-torrent request budget across peers by EWMA
5135    /// rate share and store each result in the peer's `target_depth`. The
5136    /// single-writer permit discipline lives in the reader task — this
5137    /// allocator only stores targets; return sites and unchoke refills
5138    /// converge toward them (`peer_shared::return_permit_budgeted`).
5139    ///
5140    /// Runs unconditionally from the 1 s pipeline tick: with the budget
5141    /// disabled (0) the allocator emits the legacy fixed depth for every
5142    /// peer, so a live disable restores pre-budget behaviour on the next
5143    /// tick instead of stranding shrunken targets.
5144    ///
5145    /// M257f: each pass also computes/stores the per-peer BDP depth cap
5146    /// from the rate × RTT EWMAs. Cap state updates for unchoked peers
5147    /// even when the budget is 0 (`compute_quotas` ignores caps on that
5148    /// arm) so caps stay warm across a live disable/enable cycle; for
5149    /// CHOKED peers the cap holds warm and the streak resets — their
5150    /// rate EWMA is decaying toward 0 and feeding it to the formula
5151    /// would floor the cap and block the spill-pass rescue on unchoke.
5152    fn apply_request_budget(&mut self) {
5153        use std::sync::atomic::Ordering::Relaxed;
5154
5155        // Rotation-churn suspension uses LAST tick's estimate (the edges
5156        // counted below update it for the next tick — 1 s of lag is
5157        // immaterial against a spiral that builds over seconds).
5158        let churn_suspended =
5159            self.rechoke_per_min_est > crate::request_budget::CHURN_SUSPEND_FLIPS_PER_MIN;
5160        let mut choke_edges = 0u32;
5161
5162        let mut rates: Vec<crate::request_budget::PeerRate> = Vec::with_capacity(self.peers.len());
5163        for (addr, p) in &mut self.peers {
5164            if p.peer_choking && !p.prev_choking {
5165                choke_edges += 1;
5166            }
5167            p.prev_choking = p.peer_choking;
5168            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5169            let rate = p.pipeline.ewma_rate().max(0.0) as u64;
5170            if churn_suspended {
5171                // Rotation-shared service: honest BDP is sub-block and
5172                // depth is stall-absorption — run the M257c regime
5173                // (permissive cap, budget/shares bind) until churn
5174                // subsides. Assignment, not skip: recovery is immediate.
5175                p.bdp_cap = crate::request_budget::LEGACY_DEPTH;
5176                p.bdp_shrink_streak = 0;
5177            } else if p.peer_choking {
5178                p.bdp_shrink_streak = 0;
5179            } else {
5180                // The cap prices the RAW last-window rate, not the EWMA:
5181                // a full window already reads depth-limited delivery
5182                // exactly, while the EWMA staircase spends ~9 ticks
5183                // below it and every staircase tick is a shrink/grow
5184                // knife-edge (evidence run 3 bimodality). Shares below
5185                // keep the EWMA — M257c shipped on it.
5186                let (cap, streak) = crate::request_budget::bdp_cap(
5187                    p.pipeline.last_window_rate(),
5188                    p.avg_rtt,
5189                    p.bdp_cap,
5190                    p.bdp_shrink_streak,
5191                );
5192                p.bdp_cap = cap;
5193                p.bdp_shrink_streak = streak;
5194            }
5195            rates.push((*addr, rate, !p.peer_choking, p.bdp_cap));
5196        }
5197        self.rechoke_per_min_est =
5198            0.1 * f64::from(choke_edges) * 60.0 + 0.9 * self.rechoke_per_min_est;
5199        let quotas = crate::request_budget::compute_quotas(
5200            self.config.request_budget_per_torrent,
5201            self.config.request_budget_floor,
5202            &rates,
5203        );
5204        let mut changed = false;
5205        for (addr, quota) in quotas {
5206            if let Some(peer) = self.peers.get(&addr)
5207                && peer.target_depth.swap(quota, Relaxed) != quota
5208            {
5209                changed = true;
5210            }
5211        }
5212        if changed {
5213            self.counters
5214                .inc_diag(crate::stats::BUDGET_REALLOCS_TOTAL, 1);
5215        }
5216    }
5217
5218    // ----- Choking -----
5219
5220    fn update_peer_rates(&mut self) {
5221        for peer in self.peers.values_mut() {
5222            peer.download_rate = peer.download_bytes_window / 2;
5223            peer.upload_rate = peer.upload_bytes_window / 2;
5224            peer.download_bytes_window = 0;
5225            peer.upload_bytes_window = 0;
5226        }
5227
5228        // Track peak download rate for peer turnover cutoff
5229        let aggregate_download: u64 = self.peers.values().map(|p| p.download_rate).sum();
5230        if aggregate_download > self.peak_download_rate {
5231            self.peak_download_rate = aggregate_download;
5232        }
5233    }
5234
5235    async fn run_choker(&mut self) {
5236        let peer_infos: Vec<ChokerPeerInfo> = self
5237            .peers
5238            .values()
5239            .map(|p| ChokerPeerInfo {
5240                addr: p.addr,
5241                download_rate: p.download_rate,
5242                upload_rate: p.upload_rate,
5243                interested: p.peer_interested,
5244                upload_only: p.upload_only,
5245                is_seed: p.upload_only
5246                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5247            })
5248            .collect();
5249
5250        let decision = self.choker.decide(&peer_infos);
5251
5252        let mut unchoke_flips: i64 = 0;
5253        for addr in &decision.to_unchoke {
5254            if let Some(peer) = self.peers.get_mut(addr)
5255                && peer.am_choking
5256            {
5257                peer.am_choking = false;
5258                unchoke_flips += 1;
5259                // Track unchoke window for fairness measurement.
5260                if peer.am_unchoke_started_at.is_none() {
5261                    peer.am_unchoke_started_at = Some(Instant::now());
5262                }
5263                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(false));
5264            }
5265        }
5266
5267        let mut choke_flips: i64 = 0;
5268        for addr in &decision.to_choke {
5269            if let Some(peer) = self.peers.get_mut(addr)
5270                && !peer.am_choking
5271            {
5272                if peer.supports_fast {
5273                    let pending: Vec<(u32, u32, u32)> = peer.incoming_requests.drain(..).collect();
5274                    for (index, begin, length) in pending {
5275                        let _ = peer.cmd_tx.try_send(PeerCommand::RejectRequest {
5276                            index,
5277                            begin,
5278                            length,
5279                        });
5280                    }
5281                }
5282                peer.am_choking = true;
5283                choke_flips += 1;
5284                // Accumulate the unchoke window we just closed.
5285                if let Some(start) = peer.am_unchoke_started_at.take() {
5286                    peer.unchoke_duration_total += start.elapsed();
5287                }
5288                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(true));
5289            }
5290        }
5291
5292        if unchoke_flips > 0 {
5293            self.counters
5294                .inc_diag(crate::stats::OUTBOUND_UNCHOKE_FLIPS_TOTAL, unchoke_flips);
5295        }
5296        if choke_flips > 0 {
5297            self.counters
5298                .inc_diag(crate::stats::OUTBOUND_CHOKE_FLIPS_TOTAL, choke_flips);
5299        }
5300
5301        // Serve any buffered requests from newly-unchoked peers
5302        self.serve_incoming_requests().await;
5303
5304        // Zombie pruning: disconnect peers with empty bitfields after 30s.
5305        // These peers consume connection slots but contribute no pieces.
5306        // Only prune during downloading — when seeding, empty-bitfield peers
5307        // are leechers we want to upload to.
5308        if self.state == TorrentState::Downloading {
5309            let zombie_threshold = Duration::from_secs(30);
5310            let zombies: Vec<SocketAddr> = self
5311                .peers
5312                .values()
5313                .filter(|p| {
5314                    p.bitfield.count_ones() == 0 && p.connected_at.elapsed() > zombie_threshold
5315                })
5316                .map(|p| p.addr)
5317                .collect();
5318
5319            for &addr in &zombies {
5320                debug!(%addr, "disconnecting zombie peer (empty bitfield after 30s)");
5321                self.disconnect_peer(addr, "zombie peer (empty bitfield)");
5322            }
5323            if !zombies.is_empty() {
5324                self.recalc_max_in_flight();
5325            }
5326        }
5327    }
5328
5329    fn rotate_optimistic(&mut self) {
5330        let peer_infos: Vec<ChokerPeerInfo> = self
5331            .peers
5332            .values()
5333            .map(|p| ChokerPeerInfo {
5334                addr: p.addr,
5335                download_rate: p.download_rate,
5336                upload_rate: p.upload_rate,
5337                interested: p.peer_interested,
5338                upload_only: p.upload_only,
5339                is_seed: p.upload_only
5340                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5341            })
5342            .collect();
5343
5344        self.choker.rotate_optimistic(&peer_infos);
5345    }
5346
5347    /// Handle an incoming I2P peer connection (M41).
5348    ///
5349    /// Assigns a synthetic `SocketAddr` (from the reserved 240.0.0.0/4 range) since
5350    /// I2P peers don't have real IP addresses, then hands the underlying TCP stream
5351    /// to `spawn_peer_from_stream`.
5352    fn handle_i2p_incoming(&mut self, stream: crate::i2p::SamStream) {
5353        if self.peers.len() >= self.effective_max_connections() {
5354            return;
5355        }
5356
5357        let synthetic_addr = self.next_i2p_synthetic_addr();
5358
5359        let remote_dest = stream.remote_destination().clone();
5360        let dest_preview = {
5361            let b64 = remote_dest.to_base64();
5362            if b64.len() >= 8 {
5363                b64[..8].to_string()
5364            } else {
5365                b64
5366            }
5367        };
5368        self.i2p_destinations.insert(synthetic_addr, remote_dest);
5369        let tcp_stream = stream.into_inner();
5370
5371        self.spawn_peer_from_stream(synthetic_addr, tcp_stream);
5372
5373        debug!(dest = %dest_preview, addr = %synthetic_addr, "accepted I2P peer");
5374    }
5375
5376    /// Add an I2P peer by destination, assigning a synthetic `SocketAddr`.
5377    #[allow(dead_code)] // Used by Task 2 (outbound I2P connects)
5378    fn add_i2p_peer(
5379        &mut self,
5380        dest: crate::i2p::I2pDestination,
5381        source: PeerSource,
5382    ) -> Option<SocketAddr> {
5383        // Dedup: check if we already track this destination
5384        if self.i2p_destinations.values().any(|d| d == &dest) {
5385            return None;
5386        }
5387        let addr = self.next_i2p_synthetic_addr();
5388        self.i2p_destinations.insert(addr, dest);
5389        // M137: Track via unified PeerStates lifecycle
5390        if let Some(ref ps) = self.peer_states {
5391            ps.add_if_not_seen(addr, source);
5392        }
5393        Some(addr)
5394    }
5395
5396    /// Generate a unique synthetic `SocketAddr` for an I2P peer.
5397    ///
5398    /// Uses addresses from 240.0.0.0/4 (reserved, never routable) to avoid
5399    /// conflicts with real peers. The counter ensures uniqueness across the
5400    /// torrent's lifetime.
5401    fn next_i2p_synthetic_addr(&mut self) -> SocketAddr {
5402        self.i2p_peer_counter = self.i2p_peer_counter.wrapping_add(1);
5403        let a = ((self.i2p_peer_counter >> 16) & 0x0F) as u8 | 0xF0;
5404        let b = ((self.i2p_peer_counter >> 8) & 0xFF) as u8;
5405        let c = (self.i2p_peer_counter & 0xFF) as u8;
5406        SocketAddr::new(
5407            std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, 1)),
5408            (self.i2p_peer_counter & 0xFFFF) as u16,
5409        )
5410    }
5411}
5412
5413/// Check whether a `SocketAddr` uses a synthetic I2P address (240.0.0.0/4 range).
5414pub(crate) fn is_i2p_synthetic_addr(addr: &SocketAddr) -> bool {
5415    match addr {
5416        SocketAddr::V4(v4) => v4.ip().octets()[0] & 0xF0 == 0xF0,
5417        SocketAddr::V6(_) => false,
5418    }
5419}
5420
5421/// Helper to accept a connection from an optional transport listener.
5422/// Returns `pending` if no listener is bound, so the `select!` branch is skipped.
5423async fn accept_incoming(
5424    listener: &mut Option<Box<dyn crate::transport::TransportListener>>,
5425) -> std::io::Result<(crate::transport::BoxedStream, SocketAddr)> {
5426    match listener {
5427        Some(l) => l.accept().await,
5428        None => std::future::pending().await,
5429    }
5430}
5431
5432/// Helper to receive an incoming I2P connection from the accept loop channel.
5433/// Returns `pending` if I2P is not enabled, so the `select!` branch is skipped.
5434async fn accept_i2p(
5435    rx: &mut Option<mpsc::Receiver<crate::i2p::SamStream>>,
5436) -> Option<crate::i2p::SamStream> {
5437    match rx {
5438        Some(rx) => rx.recv().await,
5439        None => std::future::pending().await,
5440    }
5441}
5442
5443// ============================================================================
5444// BEP 52 hash serving (M87)
5445// ============================================================================
5446
5447/// Determine what to serve for a BEP 52 hash request.
5448///
5449/// Returns `Some(hashes)` to serve, or `None` to reject.
5450/// Only serves piece-layer hashes (the layer stored in `piece_layers`).
5451/// Block-layer or other layer requests are rejected since we don't store
5452/// the full Merkle tree.
5453pub(crate) fn serve_hashes(
5454    meta_v2: Option<&irontide_core::TorrentMetaV2>,
5455    version: irontide_core::TorrentVersion,
5456    lengths: Option<&Lengths>,
5457    request: &irontide_core::HashRequest,
5458) -> Option<Vec<irontide_core::Id32>> {
5459    // Reject if v1-only or no v2 metadata
5460    let meta_v2 = match meta_v2 {
5461        Some(m) if version != irontide_core::TorrentVersion::V1Only => m,
5462        _ => return None,
5463    };
5464
5465    // Look up piece-layer hashes for the requested file root
5466    let piece_hashes = meta_v2.file_piece_hashes(&request.file_root)?;
5467
5468    // We need lengths to validate the request geometry
5469    let lengths = lengths?;
5470
5471    // Compute per-file block count from piece hashes and piece/chunk sizes.
5472    // Each piece hash covers `piece_length / chunk_size` blocks, except the
5473    // last piece which may cover fewer. For validation purposes we use the
5474    // padded count that `validate_hash_request` expects.
5475    let blocks_per_piece = (meta_v2.info.piece_length / u64::from(lengths.chunk_size())) as u32;
5476    let num_pieces = piece_hashes.len() as u32;
5477    let num_blocks = num_pieces.saturating_mul(blocks_per_piece);
5478
5479    if !irontide_core::validate_hash_request(request, num_blocks, num_pieces) {
5480        return None;
5481    }
5482
5483    // We only have piece-layer hashes. The piece layer is at
5484    // base = log2(blocks_per_piece). Reject requests for other layers.
5485    let piece_layer_base = blocks_per_piece.trailing_zeros();
5486    if request.base != piece_layer_base {
5487        return None;
5488    }
5489
5490    // Extract requested hashes from the piece layer
5491    let start = request.index as usize;
5492    let end = (start + request.count as usize).min(piece_hashes.len());
5493    let mut hashes: Vec<irontide_core::Id32> = piece_hashes[start..end].to_vec();
5494
5495    // Compute proof (uncle) hashes if requested.
5496    //
5497    // BEP 52 specifies a single subtree proof for the entire batch, not
5498    // per-leaf proofs. The receiver rebuilds the subtree root from the
5499    // base hashes itself, so we skip the first `log2(count)` levels of
5500    // the proof path (those are internal to the requested subtree) and
5501    // only send the uncle hashes above it.
5502    if request.proof_layers > 0 && !piece_hashes.is_empty() {
5503        let tree = irontide_core::MerkleTree::from_leaves(&piece_hashes);
5504        let full_proof = tree.proof_path(start);
5505        // Skip levels internal to the requested subtree
5506        let subtree_depth = if request.count > 1 {
5507            (request.count as usize)
5508                .next_power_of_two()
5509                .trailing_zeros() as usize
5510        } else {
5511            0
5512        };
5513        let available = full_proof.len().saturating_sub(subtree_depth);
5514        let proof_count = (request.proof_layers as usize).min(available);
5515        hashes.extend_from_slice(&full_proof[subtree_depth..subtree_depth + proof_count]);
5516    }
5517
5518    Some(hashes)
5519}
5520
5521// ============================================================================
5522// Test-only constructors
5523// ============================================================================
5524
5525#[cfg(test)]
5526impl TorrentActor {
5527    /// v0.173.3 (A4): Build a minimal `TorrentActor` exercising only the
5528    /// fields touched by `rebuild_availability_snapshot`.
5529    ///
5530    /// Every other field is filled with the cheapest valid placeholder
5531    /// (empty channels, zero atomics, no-op handles). The actor is **not**
5532    /// spawned via `tokio::spawn` so it has no live `run()` loop — the
5533    /// returned struct is suitable for direct method-level testing only.
5534    ///
5535    /// `num_pieces` controls the size of the pre-allocated availability
5536    /// vector and atomic-states bitmap. `throttle_ms` plumbs the v0.173.3
5537    /// throttle config into the synthetic actor's `TorrentConfig`.
5538    ///
5539    /// Must run inside a tokio runtime because `DiskManagerHandle::new`
5540    /// internally spawns its background actor.
5541    pub(crate) fn for_throttle_test(num_pieces: u32, _throttle_ms: u64) -> Self {
5542        use irontide_storage::Bitfield;
5543
5544        let config = TorrentConfig {
5545            ..TorrentConfig::default()
5546        };
5547
5548        let info_hash = Id20([0u8; 20]);
5549        let our_peer_id = Id20([0u8; 20]);
5550
5551        let (_cmd_tx, cmd_rx) = mpsc::channel(1);
5552        let (event_tx, event_rx) = mpsc::channel(1);
5553        let (write_error_tx, write_error_rx) = mpsc::channel(1);
5554        let (verify_result_tx, verify_result_rx) = mpsc::channel(1);
5555        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
5556        let (piece_ready_tx, _piece_ready_rx) = broadcast::channel(1);
5557        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(num_pieces));
5558        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
5559        let (alert_tx, _alert_rx) = broadcast::channel(64);
5560        let (_disk_mgr_tx, _disk_mgr_rx) = mpsc::channel::<crate::disk::DiskJob>(1);
5561
5562        let stream_read_semaphore = Arc::new(tokio::sync::Semaphore::new(8));
5563        let alert_mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5564
5565        // DiskManagerHandle::new spawns an actor — requires runtime.
5566        let (disk_manager, _disk_join) =
5567            crate::disk::DiskManagerHandle::new(crate::disk::DiskConfig::default());
5568
5569        let ban_manager = Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5570            crate::ban::BanConfig::default(),
5571        )));
5572        let ip_filter = Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
5573
5574        let upload_bucket = crate::rate_limiter::TokenBucket::new(0);
5575        let download_bucket = Arc::new(parking_lot::Mutex::new(
5576            crate::rate_limiter::TokenBucket::new(0),
5577        ));
5578        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(0, 0, 0, 0, 0, 0);
5579
5580        let dht_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5581        let dht_v6_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5582        let factory = Arc::new(crate::transport::NetworkFactory::tokio());
5583
5584        // Atomic states + availability sized for `num_pieces`. The
5585        // availability snapshot rebuild reads both; everything else
5586        // (chunk tracker, file priorities, peers) can stay empty.
5587        let we_have = Bitfield::new(num_pieces);
5588        let mut wanted = Bitfield::new(num_pieces);
5589        for i in 0..num_pieces {
5590            wanted.set(i);
5591        }
5592        let atomic_states = Arc::new(crate::piece_reservation::AtomicPieceStates::new(
5593            num_pieces, &we_have, &wanted,
5594        ));
5595
5596        let (order_map_tx, _order_map_rx_seed) =
5597            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
5598
5599        Self {
5600            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(0),
5601            config,
5602            info_hash,
5603            our_peer_id,
5604            state: TorrentState::Downloading,
5605            disk: None,
5606            disk_manager,
5607            chunk_tracker: None,
5608            lengths: None,
5609            num_pieces,
5610            file_priorities: Vec::new(),
5611            wanted_pieces: Bitfield::new(num_pieces),
5612            end_game: EndGame::new(),
5613            streaming_pieces: BTreeSet::new(),
5614            time_critical_pieces: BTreeSet::new(),
5615            streaming_cursors: Vec::new(),
5616            piece_ready_tx,
5617            have_watch_tx,
5618            have_watch_rx,
5619            stream_read_semaphore,
5620            peers: HashMap::new(),
5621            unchoke_durations: HashMap::new(),
5622            cached_peer_rates: FxHashMap::default(),
5623            refill_notify: Arc::new(tokio::sync::Notify::new()),
5624            atomic_states: Some(atomic_states),
5625            block_maps: None,
5626            steal_candidates: None,
5627            last_steal_populate: Instant::now(),
5628            piece_write_guards: None,
5629            soft_reap_buf: Vec::new(),
5630            eviction_history: std::collections::VecDeque::new(),
5631            force_immediate_choker_tick: false,
5632            piece_tracker: None,
5633            order_map_dirty: false,
5634            next_order_map_gen: 0,
5635            order_map_tx,
5636            piece_owner: vec![None; num_pieces as usize],
5637            peer_slab: crate::piece_reservation::PeerSlab::new(),
5638            priority_pieces: BTreeSet::new(),
5639            max_in_flight: 512,
5640            reservation_notify: None,
5641            last_tick_dispatch_state: None,
5642            choker: Choker::new(4),
5643            user_seed_mode: false,
5644            user_forced: false,
5645            max_connections: 0,
5646            peer_states: None,
5647            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
5648            connect_permits: HashMap::new(),
5649            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
5650                std::collections::HashMap::new(),
5651            )),
5652            connect_rx: None,
5653            metadata_downloader: None,
5654            meta: None,
5655            cached_files: None,
5656            downloaded: 0,
5657            uploaded: 0,
5658            checking_progress: 0.0,
5659            total_download: 0,
5660            total_upload: 0,
5661            total_failed_bytes: 0,
5662            total_redundant_bytes: 0,
5663            added_time: 0,
5664            completed_time: 0,
5665            last_download: 0,
5666            last_upload: 0,
5667            last_seen_complete: 0,
5668            active_duration: 0,
5669            finished_duration: 0,
5670            seeding_duration: 0,
5671            active_since: None,
5672            state_duration_since: None,
5673            started_at: Instant::now(),
5674            moving_storage: false,
5675            has_incoming: false,
5676            need_save_resume: false,
5677            error: String::new(),
5678            error_file: -1,
5679            cmd_rx,
5680            event_tx,
5681            event_rx,
5682            write_error_rx,
5683            write_error_tx,
5684            verify_result_rx,
5685            verify_result_tx,
5686            pending_verify: HashSet::new(),
5687            piece_generations: vec![0u64; num_pieces as usize],
5688            hash_result_rx,
5689            hash_result_tx,
5690            listener: None,
5691            utp_socket: None,
5692            utp_socket_v6: None,
5693            tracker_manager: TrackerManager::empty(info_hash, our_peer_id, 0, 0, false),
5694            tracker_result_rx: None,
5695            dht_rx,
5696            dht_v6_rx,
5697            dht_enabled: false,
5698            dht_peers_rx: None,
5699            dht_v6_peers_rx: None,
5700            dht_v6_empty_count: 0,
5701            dht_v6_last_retry: None,
5702            alert_tx,
5703            alert_mask,
5704            upload_bucket,
5705            download_bucket,
5706            global_upload_bucket: None,
5707            global_download_bucket: None,
5708            slot_tuner: crate::slot_tuner::SlotTuner::disabled(4),
5709            upload_bytes_interval: 0,
5710            peak_download_rate: 0,
5711            rechoke_per_min_est: 0.0,
5712            web_seeds: HashMap::new(),
5713            banned_web_seeds: HashSet::new(),
5714            web_seed_in_flight: HashMap::new(),
5715            web_seed_stats: HashMap::new(),
5716            pex_peer_count: 0,
5717            lsd_peer_count: 0,
5718            super_seed: None,
5719            have_broadcast_tx,
5720            suggested_to_peers: HashMap::new(),
5721            predictive_have_sent: HashSet::new(),
5722            ban_manager,
5723            piece_contributors: HashMap::new(),
5724            parole_pieces: HashMap::new(),
5725            ip_filter,
5726            external_ip: None,
5727            share_lru: std::collections::VecDeque::new(),
5728            share_max_pieces: 0,
5729            plugins: Arc::new(Vec::new()),
5730            hash_picker: None,
5731            version: irontide_core::TorrentVersion::V1Only,
5732            meta_v2: None,
5733            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
5734            dht_v2_peers_rx: None,
5735            dht_v6_v2_peers_rx: None,
5736            magnet_selected_files: None,
5737            sam_session: None,
5738            i2p_accept_rx: None,
5739            i2p_peer_counter: 0,
5740            i2p_destinations: HashMap::new(),
5741            ssl_manager: None,
5742            rate_limiter_set,
5743            auto_sequential_active: false,
5744            factory,
5745            hash_pool_ref: None,
5746            connect_attempts: 0,
5747            connect_failures: 0,
5748            choke_rotations: 0,
5749            inflight_started: Vec::new(),
5750            completed_piece_times: std::collections::VecDeque::new(),
5751            piece_steals: 0,
5752            holepunch_relayed: 0,
5753            holepunch_relay_rate: HashMap::new(),
5754            holepunch_cooldowns: HashMap::new(),
5755            holepunch_pending: Vec::new(),
5756            counters: Arc::new(crate::stats::SessionCounters::new()),
5757        }
5758    }
5759}
5760
5761// ============================================================================
5762/// Bytes left to download, as reported to trackers (BEP 3 `left`).
5763///
5764/// Every verified piece contributes exactly `piece_length` bytes except the
5765/// final piece, which is shorter whenever `total` is not an exact multiple of
5766/// `piece_length`. The final piece can verify at any point in the download —
5767/// piece order is not sequential, and first/last-pieces-first mode makes early
5768/// completion of the last piece likely — so its short size is corrected for
5769/// whenever its bit is set, not only at completion.
5770///
5771/// History: the pre-PR-#3 implementation divided `total` by the piece *count*
5772/// (a rounded-down average), so completed torrents announced
5773/// `left = total % pieces_total` and trackers never saw `left = 0`.
5774fn compute_bytes_left(
5775    total: u64,
5776    piece_length: u64,
5777    pieces_total: u64,
5778    have: u64,
5779    last_piece_have: bool,
5780) -> u64 {
5781    if pieces_total == 0 || have >= pieces_total {
5782        return 0;
5783    }
5784    let mut downloaded = have.saturating_mul(piece_length);
5785    if last_piece_have {
5786        // The last piece was counted at full length above; correct it to its
5787        // true (shorter) size. Saturating ops keep hostile metainfo (where
5788        // `(pieces_total - 1) * piece_length > total`) from underflowing.
5789        let last_piece_size = total.saturating_sub((pieces_total - 1).saturating_mul(piece_length));
5790        downloaded = downloaded
5791            .saturating_sub(piece_length)
5792            .saturating_add(last_piece_size);
5793    }
5794    total.saturating_sub(downloaded)
5795}
5796
5797// Tests
5798// ============================================================================
5799
5800#[cfg(test)]
5801mod tests {
5802    use super::*;
5803    use bytes::Bytes;
5804    use futures::{SinkExt, StreamExt};
5805    use irontide_wire::{ExtHandshake, Handshake, Message, MessageCodec};
5806    use std::time::Duration;
5807    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5808    use tokio::net::TcpListener;
5809    use tokio_util::codec::{FramedRead, FramedWrite};
5810
5811    // PR #3 regression suite — compute_bytes_left (tracker announce `left`).
5812    // The pre-fix formula divided by the piece COUNT (a rounded-down average),
5813    // so a completed torrent announced `left = total % pieces_total`, never 0.
5814    // Canonical fixture: total=1001, piece_length=256 → 4 pieces (256/256/256/233).
5815
5816    #[test]
5817    fn bytes_left_zero_at_completion_with_short_last_piece() {
5818        // The original bug: the old formula reported 1001 % 4 = 1 here.
5819        assert_eq!(compute_bytes_left(1001, 256, 4, 4, true), 0);
5820    }
5821
5822    #[test]
5823    fn bytes_left_total_at_start() {
5824        assert_eq!(compute_bytes_left(1001, 256, 4, 0, false), 1001);
5825    }
5826
5827    #[test]
5828    fn bytes_left_exact_without_last_piece() {
5829        // Two full pieces verified, last piece not among them.
5830        assert_eq!(compute_bytes_left(1001, 256, 4, 2, false), 1001 - 512);
5831    }
5832
5833    #[test]
5834    fn bytes_left_exact_when_short_last_piece_verifies_early() {
5835        // One full piece + the 233-byte last piece verified: exactly two full
5836        // pieces (512 bytes) remain. Counting the last piece at full length
5837        // would understate this as 489.
5838        assert_eq!(compute_bytes_left(1001, 256, 4, 2, true), 512);
5839    }
5840
5841    #[test]
5842    fn bytes_left_exact_multiple_total_has_full_last_piece() {
5843        // total divides evenly: the last-piece correction must be a no-op.
5844        assert_eq!(compute_bytes_left(1024, 256, 4, 3, true), 256);
5845        assert_eq!(compute_bytes_left(1024, 256, 4, 4, true), 0);
5846    }
5847
5848    #[test]
5849    fn bytes_left_single_piece_torrent() {
5850        // piece_length exceeds total: the single piece IS the short last piece.
5851        assert_eq!(compute_bytes_left(100, 256, 1, 0, false), 100);
5852        assert_eq!(compute_bytes_left(100, 256, 1, 1, true), 0);
5853    }
5854
5855    #[test]
5856    fn bytes_left_degenerate_inputs_saturate_without_panic() {
5857        // Zero pieces short-circuits.
5858        assert_eq!(compute_bytes_left(0, 0, 0, 0, false), 0);
5859        // Hostile metainfo ((pieces-1)*piece_length > total): the last-piece
5860        // size clamps to 0, so the lone verified "last" piece contributes
5861        // nothing and the full total is still reported as left. No underflow.
5862        assert_eq!(compute_bytes_left(10, 256, 4, 1, true), 10);
5863    }
5864
5865    // M224: initial unchoke slot derivation from Settings.max_uploads_per_torrent.
5866
5867    #[test]
5868    fn initial_unchoke_slots_unlimited_returns_default_four() {
5869        assert_eq!(initial_unchoke_slots(-1), 4);
5870    }
5871
5872    #[test]
5873    fn initial_unchoke_slots_capped_returns_value() {
5874        assert_eq!(initial_unchoke_slots(1), 1);
5875        assert_eq!(initial_unchoke_slots(4), 4);
5876        assert_eq!(initial_unchoke_slots(16), 16);
5877    }
5878
5879    // -- Helpers --
5880
5881    /// Build a valid `TorrentMetaV1` from raw data with given piece length.
5882    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
5883        use serde::Serialize;
5884
5885        #[derive(Serialize)]
5886        struct Info<'a> {
5887            length: u64,
5888            name: &'a str,
5889            #[serde(rename = "piece length")]
5890            piece_length: u64,
5891            #[serde(with = "serde_bytes")]
5892            pieces: &'a [u8],
5893        }
5894
5895        #[derive(Serialize)]
5896        struct Torrent<'a> {
5897            info: Info<'a>,
5898        }
5899
5900        let mut pieces = Vec::new();
5901        let mut offset = 0;
5902        while offset < data.len() {
5903            let end = (offset + piece_length as usize).min(data.len());
5904            let hash = irontide_core::sha1(&data[offset..end]);
5905            pieces.extend_from_slice(hash.as_bytes());
5906            offset = end;
5907        }
5908
5909        let t = Torrent {
5910            info: Info {
5911                length: data.len() as u64,
5912                name: "test",
5913                piece_length,
5914                pieces: &pieces,
5915            },
5916        };
5917
5918        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5919        torrent_from_bytes(&bytes).unwrap()
5920    }
5921
5922    fn test_config() -> TorrentConfig {
5923        TorrentConfig {
5924            listen_port: 0, // random port
5925            max_peers: 200,
5926            target_request_queue: 5,
5927            download_dir: std::path::PathBuf::from("/tmp"),
5928            content_layout: irontide_session_types::ContentLayout::Original,
5929            enable_dht: false,
5930            enable_pex: false,
5931            enable_fast: false,
5932            seed_ratio_limit: None,
5933            seed_time_limit_secs: None,
5934            inactive_seed_time_limit_secs: None,
5935            strict_end_game: true,
5936            upload_rate_limit: 0,
5937            download_rate_limit: 0,
5938            max_uploads_per_torrent: -1,
5939            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
5940            enable_utp: false,
5941            enable_web_seed: true,
5942            enable_holepunch: false,
5943            enable_bep40_eviction: true,
5944            max_web_seeds: 4,
5945            web_seed_retry_base_secs: 10,
5946            web_seed_retry_factor: 6,
5947            web_seed_retry_cap_secs: 3600,
5948            web_seed_max_failures: 10,
5949            super_seeding: false,
5950            upload_only_announce: true,
5951            hashing_threads: 2,
5952            sequential_download: false,
5953            prioritize_first_last_pieces: false,
5954            file_priorities: Vec::new(),
5955            initial_picker_threshold: 4,
5956            whole_pieces_threshold: 20,
5957            snub_timeout_secs: 15,
5958            readahead_pieces: 8,
5959            streaming_timeout_escalation: true,
5960            max_concurrent_stream_reads: 8,
5961            proxy: crate::proxy::ProxyConfig::default(),
5962            anonymous_mode: false,
5963            share_mode: false,
5964            enable_i2p: false,
5965            allow_i2p_mixed: false,
5966            ssl_listen_port: 0,
5967            seed_choking_algorithm: crate::choker::SeedChokingAlgorithm::FastestUpload,
5968            choking_algorithm: crate::choker::ChokingAlgorithm::FixedSlots,
5969            piece_extent_affinity: true,
5970            suggest_mode: false,
5971            max_suggest_pieces: 10,
5972            predictive_piece_announce_ms: 0,
5973            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
5974            auto_sequential: true,
5975            preallocate_mode: irontide_storage::PreallocateMode::None,
5976            block_request_timeout_secs: 60,
5977            enable_lsd: false,
5978            force_proxy: false,
5979            steal_threshold_ratio: 10.0,
5980            steal_threshold_endgame: 3.0,
5981            peer_read_timeout_secs: 0,         // disabled in tests
5982            peer_write_timeout_secs: 0,        // disabled in tests
5983            data_contribution_timeout_secs: 0, // disabled in tests
5984            // v0.187.3 eviction tunables — defaults that match production.
5985            pass0_grace_secs: 60,
5986            proactive_evictions_per_minute_limit: 30,
5987            eviction_ban_duration_secs: 600,
5988            eviction_ban_set_cap: 1024,
5989            choke_rotation_max_evictions: 0, // disabled in tests
5990            max_concurrent_connects: 128,
5991            connect_soft_timeout: 3,
5992            dispatch_backlog_cap: 8,
5993            event_backlog_cap: 32,
5994            peer_writer_channel_cap: 1024,
5995            use_actor_dispatch: true,
5996            web_seed_progress_throttle_ms: 250,
5997            url_security: crate::url_guard::UrlSecurityConfig::default(),
5998            peer_connect_timeout: 2,
5999            peer_dscp: 0x08,
6000            initial_queue_depth: 128,
6001            max_request_queue_depth: 250,
6002            request_budget_per_torrent: 512,
6003            request_budget_floor: 8,
6004            request_queue_time: 3.0,
6005            max_metadata_size: 4 * 1024 * 1024,
6006            max_message_size: 16 * 1024 * 1024,
6007            max_piece_length: 32 * 1024 * 1024,
6008            max_outstanding_requests: 500,
6009            max_in_flight_pieces: 20,
6010            use_block_stealing: true,
6011            steal_stale_piece_secs: 2,
6012            fixed_pipeline_depth: 128,
6013            lock_warn_threshold_ms: 0, // disabled in tests
6014            filesystem_direct_io: false,
6015            category: None,
6016            tags: Vec::new(),
6017        }
6018    }
6019
6020    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
6021        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
6022        Arc::new(MemoryStorage::new(lengths))
6023    }
6024
6025    fn make_seeded_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
6026        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
6027        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
6028        // Write data piece by piece
6029        let num_pieces = lengths.num_pieces();
6030        for p in 0..num_pieces {
6031            let piece_size = lengths.piece_size(p) as usize;
6032            let offset = lengths.piece_offset(p) as usize;
6033            let end = offset + piece_size;
6034            storage.write_chunk(p, 0, &data[offset..end]).unwrap();
6035        }
6036        storage
6037    }
6038
6039    fn test_alert_channel() -> (broadcast::Sender<Alert>, Arc<AtomicU32>) {
6040        let (tx, _) = broadcast::channel(64);
6041        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
6042        (tx, mask)
6043    }
6044
6045    fn test_ban_manager() -> irontide_session_types::SharedBanManager {
6046        Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
6047            crate::ban::BanConfig::default(),
6048        )))
6049    }
6050
6051    fn test_ip_filter() -> irontide_session_types::SharedIpFilter {
6052        Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()))
6053    }
6054
6055    fn test_disk_manager() -> (DiskManagerHandle, tokio::task::JoinHandle<()>) {
6056        DiskManagerHandle::new(crate::disk::DiskConfig::default())
6057    }
6058
6059    async fn test_register_disk(
6060        info_hash: Id20,
6061        storage: Arc<dyn TorrentStorage>,
6062    ) -> (DiskHandle, DiskManagerHandle, tokio::task::JoinHandle<()>) {
6063        let (dm, join) = test_disk_manager();
6064        let dh = dm.register_torrent(info_hash, storage).await;
6065        (dh, dm, join)
6066    }
6067
6068    /// M173 Lane B (B6): build a `DhtReceiver` pre-populated with `None`
6069    /// — what the test fixtures previously passed as `dht: None`.
6070    fn test_dht_rx() -> irontide_dht::DhtReceiver {
6071        // `&'static` storage so each call returns a fresh subscriber
6072        // without leaking the underlying broadcast.
6073        let bx = irontide_dht::DhtBroadcast::new(None);
6074        bx.subscribe()
6075    }
6076
6077    /// Handshake size constant.
6078    const HANDSHAKE_SIZE: usize = 68;
6079
6080    // ---- Test 1: Create from torrent ----
6081
6082    #[tokio::test]
6083    async fn create_from_torrent() {
6084        let data = vec![0xAB; 32768]; // 32 KiB
6085        let meta = make_test_torrent(&data, 16384); // 2 pieces
6086        let storage = make_storage(&data, 16384);
6087        let config = test_config();
6088
6089        let (atx, amask) = test_alert_channel();
6090        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6091        let handle = TorrentHandle::from_torrent(
6092            meta,
6093            irontide_core::TorrentVersion::V1Only,
6094            None,
6095            dh,
6096            dm,
6097            config,
6098            test_dht_rx(),
6099            test_dht_rx(),
6100            None,
6101            None,
6102            crate::slot_tuner::SlotTuner::disabled(4),
6103            atx,
6104            amask,
6105            None,
6106            None,
6107            test_ban_manager(),
6108            test_ip_filter(),
6109            Arc::new(Vec::new()),
6110            None,
6111            None,
6112            Arc::new(crate::transport::NetworkFactory::tokio()),
6113            None, // M96: hash_pool
6114            Arc::new(crate::stats::SessionCounters::new()),
6115        )
6116        .await
6117        .unwrap();
6118
6119        let stats = handle.stats().await.unwrap();
6120        assert_eq!(stats.state, TorrentState::Downloading);
6121        assert_eq!(stats.pieces_total, 2);
6122        assert_eq!(stats.pieces_have, 0);
6123        assert_eq!(stats.peers_connected, 0);
6124
6125        handle.shutdown().await.unwrap();
6126    }
6127
6128    // ---- M245 F1: atomic TakeResumeIfDirty / MarkResumeDirty ----
6129
6130    /// Build a started `TorrentHandle` over a 2-piece in-memory torrent.
6131    /// Returns the handle, the independently-derived expected piece-hash hex
6132    /// (v1 SHA-1, computed from `meta.info.pieces` BEFORE the meta is consumed —
6133    /// the parity oracle for L3), and the disk-manager join handle (kept alive
6134    /// by the caller — dropping it would abort the disk-manager task). Mirrors
6135    /// `create_from_torrent`'s construction so the tests drive a real actor.
6136    async fn started_test_handle() -> (TorrentHandle, Vec<String>, tokio::task::JoinHandle<()>) {
6137        let data = vec![0xAB; 32768]; // 32 KiB → 2 pieces at 16 KiB
6138        let meta = make_test_torrent(&data, 16384);
6139        let expected_hex: Vec<String> =
6140            meta.info.pieces.chunks_exact(20).map(hex::encode).collect();
6141        let storage = make_storage(&data, 16384);
6142        let config = test_config();
6143
6144        let (atx, amask) = test_alert_channel();
6145        let (dh, dm, dj) = test_register_disk(meta.info_hash, storage).await;
6146        let handle = TorrentHandle::from_torrent(
6147            meta,
6148            irontide_core::TorrentVersion::V1Only,
6149            None,
6150            dh,
6151            dm,
6152            config,
6153            test_dht_rx(),
6154            test_dht_rx(),
6155            None,
6156            None,
6157            crate::slot_tuner::SlotTuner::disabled(4),
6158            atx,
6159            amask,
6160            None,
6161            None,
6162            test_ban_manager(),
6163            test_ip_filter(),
6164            Arc::new(Vec::new()),
6165            None,
6166            None,
6167            Arc::new(crate::transport::NetworkFactory::tokio()),
6168            None,
6169            Arc::new(crate::stats::SessionCounters::new()),
6170        )
6171        .await
6172        .unwrap();
6173        (handle, expected_hex, dj)
6174    }
6175
6176    /// F1 atomicity: a dirty torrent yields resume data exactly ONCE — the take
6177    /// clears `need_save_resume` in the same actor turn, so the immediate second
6178    /// take sees a clean torrent and returns `None`. This is the property the
6179    /// pre-M245 `stats()`→`save_resume_data()`→`clear_save_resume_flag()`
6180    /// three-step could not guarantee (a dirty mark landing between the separate
6181    /// check and clear was lost).
6182    #[tokio::test]
6183    async fn take_resume_if_dirty_is_atomic_capture_and_clear() {
6184        let (handle, _expected_hex, _dj) = started_test_handle().await;
6185
6186        // SetTags is a synchronous, explicit `need_save_resume = true` with a
6187        // reply — a deterministic dirty trigger (no reliance on state-machine
6188        // side effects).
6189        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
6190
6191        let first = handle.take_resume_if_dirty().await.unwrap();
6192        assert!(first.is_some(), "dirty torrent must yield resume data");
6193
6194        let second = handle.take_resume_if_dirty().await.unwrap();
6195        assert!(
6196            second.is_none(),
6197            "flag was cleared atomically in the same take — no second capture"
6198        );
6199
6200        handle.shutdown().await.unwrap();
6201    }
6202
6203    /// F1 retry guarantee (RATIFIED D3-A): after a take has already cleared the
6204    /// flag, a simulated off-actor write failure re-dirties via
6205    /// `mark_resume_dirty`, and the NEXT take re-captures — the resume update is
6206    /// not lost. Without the re-dirty the torrent would stay clean and skip its
6207    /// retry until it next mutated, risking a stale `.resume` on disk.
6208    #[tokio::test]
6209    async fn mark_resume_dirty_restores_capture_after_write_failure() {
6210        let (handle, _expected_hex, _dj) = started_test_handle().await;
6211
6212        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
6213
6214        let captured = handle.take_resume_if_dirty().await.unwrap();
6215        assert!(captured.is_some(), "dirty torrent captured once");
6216
6217        // Clean now — confirm the take cleared it before we re-dirty.
6218        let between = handle.take_resume_if_dirty().await.unwrap();
6219        assert!(between.is_none(), "take cleared the flag");
6220
6221        // Simulate run_resume_save_jobs' write failure path re-arming the flag.
6222        handle.mark_resume_dirty().await.unwrap();
6223
6224        let recaptured = handle.take_resume_if_dirty().await.unwrap();
6225        assert!(
6226            recaptured.is_some(),
6227            "re-dirtied torrent must re-capture — no lost resume update"
6228        );
6229
6230        handle.shutdown().await.unwrap();
6231    }
6232
6233    /// L3 (M245): `get_piece_hashes` returns correctly hex-encoded v1 SHA-1
6234    /// piece hashes. Behaviour-parity guard for moving the `hex::encode` OFF the
6235    /// recv loop (the actor now returns raw bytes for the window; the handle
6236    /// method encodes) — the public `Vec<String>` output must stay byte-identical
6237    /// to encoding `meta.info.pieces` directly. Also pins the windowing
6238    /// semantics: `[offset, offset+limit)` clamped to the hash count.
6239    #[tokio::test]
6240    async fn get_piece_hashes_hex_parity_and_windowing() {
6241        let (handle, expected_hex, _dj) = started_test_handle().await;
6242        assert_eq!(expected_hex.len(), 2, "2-piece test torrent");
6243
6244        // Full range: output equals the independently-computed hex.
6245        let all = handle.get_piece_hashes(0, 1000).await.unwrap();
6246        assert_eq!(
6247            all, expected_hex,
6248            "hex output must match the raw piece hashes"
6249        );
6250
6251        // Window: offset 1, limit 1 → only the second hash.
6252        let windowed = handle.get_piece_hashes(1, 1).await.unwrap();
6253        assert_eq!(windowed, vec![expected_hex[1].clone()]);
6254
6255        // offset 0, limit 1 → only the first.
6256        let first = handle.get_piece_hashes(0, 1).await.unwrap();
6257        assert_eq!(first, vec![expected_hex[0].clone()]);
6258
6259        // Offset past the end → empty (clamped, no panic).
6260        let past = handle.get_piece_hashes(99, 5).await.unwrap();
6261        assert!(past.is_empty(), "offset past end yields empty");
6262
6263        // limit beyond end → clamped to what remains.
6264        let clamped = handle.get_piece_hashes(1, 1000).await.unwrap();
6265        assert_eq!(clamped, vec![expected_hex[1].clone()]);
6266
6267        handle.shutdown().await.unwrap();
6268    }
6269
6270    // ---- Test 2: Create from magnet ----
6271
6272    #[tokio::test]
6273    async fn create_from_magnet() {
6274        let magnet = Magnet {
6275            info_hashes: irontide_core::InfoHashes::v1_only(
6276                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
6277            ),
6278            display_name: Some("test".into()),
6279            trackers: vec![],
6280            peers: vec![],
6281            selected_files: None,
6282        };
6283        let config = test_config();
6284
6285        let (atx, amask) = test_alert_channel();
6286        let (dm, _dj) = test_disk_manager();
6287        let handle = TorrentHandle::from_magnet(
6288            magnet,
6289            dm,
6290            config,
6291            test_dht_rx(),
6292            test_dht_rx(),
6293            None,
6294            None,
6295            crate::slot_tuner::SlotTuner::disabled(4),
6296            atx,
6297            amask,
6298            None,
6299            None,
6300            test_ban_manager(),
6301            test_ip_filter(),
6302            Arc::new(Vec::new()),
6303            None,
6304            None,
6305            Arc::new(crate::transport::NetworkFactory::tokio()),
6306            None, // M96: hash_pool
6307            Arc::new(crate::stats::SessionCounters::new()),
6308        )
6309        .await
6310        .unwrap();
6311
6312        let stats = handle.stats().await.unwrap();
6313        assert_eq!(stats.state, TorrentState::FetchingMetadata);
6314        assert_eq!(stats.pieces_total, 0);
6315
6316        handle.shutdown().await.unwrap();
6317    }
6318
6319    // ---- Test 3: Add peers ----
6320
6321    #[tokio::test]
6322    async fn add_peers_increases_available() {
6323        let data = vec![0xAB; 32768];
6324        let meta = make_test_torrent(&data, 16384);
6325        let storage = make_storage(&data, 16384);
6326        let config = test_config();
6327
6328        let (atx, amask) = test_alert_channel();
6329        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6330        let handle = TorrentHandle::from_torrent(
6331            meta,
6332            irontide_core::TorrentVersion::V1Only,
6333            None,
6334            dh,
6335            dm,
6336            config,
6337            test_dht_rx(),
6338            test_dht_rx(),
6339            None,
6340            None,
6341            crate::slot_tuner::SlotTuner::disabled(4),
6342            atx,
6343            amask,
6344            None,
6345            None,
6346            test_ban_manager(),
6347            test_ip_filter(),
6348            Arc::new(Vec::new()),
6349            None,
6350            None,
6351            Arc::new(crate::transport::NetworkFactory::tokio()),
6352            None, // M96: hash_pool
6353            Arc::new(crate::stats::SessionCounters::new()),
6354        )
6355        .await
6356        .unwrap();
6357
6358        // Bind listeners so the connect attempts succeed and peers stay in connected state
6359        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6360        let addr1 = listener1.local_addr().unwrap();
6361        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6362        let addr2 = listener2.local_addr().unwrap();
6363
6364        handle
6365            .add_peers(vec![addr1, addr2], PeerSource::Tracker)
6366            .await
6367            .unwrap();
6368
6369        // Small delay for the actor to process
6370        tokio::time::sleep(Duration::from_millis(100)).await;
6371
6372        let stats = handle.stats().await.unwrap();
6373        // Peers may be available or already connecting (try_connect_peers fires immediately)
6374        assert!(
6375            stats.peers_available + stats.peers_connected >= 2,
6376            "expected at least 2 peers known, got available={}, connected={}",
6377            stats.peers_available,
6378            stats.peers_connected
6379        );
6380
6381        handle.shutdown().await.unwrap();
6382    }
6383
6384    // ---- Test 4: Stats reporting ----
6385
6386    #[tokio::test]
6387    async fn stats_reporting() {
6388        let data = vec![0xAB; 65536]; // 64 KiB
6389        let meta = make_test_torrent(&data, 16384); // 4 pieces
6390        let storage = make_storage(&data, 16384);
6391        let config = test_config();
6392
6393        let (atx, amask) = test_alert_channel();
6394        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6395        let handle = TorrentHandle::from_torrent(
6396            meta,
6397            irontide_core::TorrentVersion::V1Only,
6398            None,
6399            dh,
6400            dm,
6401            config,
6402            test_dht_rx(),
6403            test_dht_rx(),
6404            None,
6405            None,
6406            crate::slot_tuner::SlotTuner::disabled(4),
6407            atx,
6408            amask,
6409            None,
6410            None,
6411            test_ban_manager(),
6412            test_ip_filter(),
6413            Arc::new(Vec::new()),
6414            None,
6415            None,
6416            Arc::new(crate::transport::NetworkFactory::tokio()),
6417            None, // M96: hash_pool
6418            Arc::new(crate::stats::SessionCounters::new()),
6419        )
6420        .await
6421        .unwrap();
6422
6423        let stats = handle.stats().await.unwrap();
6424        assert_eq!(stats.state, TorrentState::Downloading);
6425        assert_eq!(stats.downloaded, 0);
6426        assert_eq!(stats.uploaded, 0);
6427        assert_eq!(stats.pieces_have, 0);
6428        assert_eq!(stats.pieces_total, 4);
6429        assert_eq!(stats.peers_connected, 0);
6430        assert_eq!(stats.peers_available, 0);
6431
6432        handle.shutdown().await.unwrap();
6433    }
6434
6435    // ---- Test 5: Private torrent disables DHT/PEX ----
6436
6437    #[tokio::test]
6438    async fn private_torrent_disables_dht_pex() {
6439        // Build a private torrent by embedding private=1 in the info dict
6440        use serde::Serialize;
6441
6442        #[derive(Serialize)]
6443        struct Info<'a> {
6444            length: u64,
6445            name: &'a str,
6446            #[serde(rename = "piece length")]
6447            piece_length: u64,
6448            #[serde(with = "serde_bytes")]
6449            pieces: &'a [u8],
6450            private: i64,
6451        }
6452
6453        #[derive(Serialize)]
6454        struct Torrent<'a> {
6455            info: Info<'a>,
6456        }
6457
6458        let data = vec![0xAB; 16384];
6459        let hash = irontide_core::sha1(&data);
6460        let mut pieces = Vec::new();
6461        pieces.extend_from_slice(hash.as_bytes());
6462
6463        let t = Torrent {
6464            info: Info {
6465                length: data.len() as u64,
6466                name: "private_test",
6467                piece_length: 16384,
6468                pieces: &pieces,
6469                private: 1,
6470            },
6471        };
6472
6473        let bytes = irontide_bencode::to_bytes(&t).unwrap();
6474        let meta = torrent_from_bytes(&bytes).unwrap();
6475        assert_eq!(meta.info.private, Some(1));
6476
6477        let storage = make_storage(&data, 16384);
6478        let mut config = test_config();
6479        config.enable_dht = true;
6480        config.enable_pex = true;
6481
6482        // The from_torrent constructor should disable DHT and PEX
6483        let (atx, amask) = test_alert_channel();
6484        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6485        let handle = TorrentHandle::from_torrent(
6486            meta,
6487            irontide_core::TorrentVersion::V1Only,
6488            None,
6489            dh,
6490            dm,
6491            config,
6492            test_dht_rx(),
6493            test_dht_rx(),
6494            None,
6495            None,
6496            crate::slot_tuner::SlotTuner::disabled(4),
6497            atx,
6498            amask,
6499            None,
6500            None,
6501            test_ban_manager(),
6502            test_ip_filter(),
6503            Arc::new(Vec::new()),
6504            None,
6505            None,
6506            Arc::new(crate::transport::NetworkFactory::tokio()),
6507            None, // M96: hash_pool
6508            Arc::new(crate::stats::SessionCounters::new()),
6509        )
6510        .await
6511        .unwrap();
6512
6513        // We can't directly inspect the actor's config, but we can verify
6514        // the torrent was created successfully. The real test is that PEX peers
6515        // would be ignored and DHT not used. For now verify the handle works.
6516        let stats = handle.stats().await.unwrap();
6517        assert_eq!(stats.state, TorrentState::Downloading);
6518
6519        handle.shutdown().await.unwrap();
6520    }
6521
6522    // ---- Test 6: Shutdown cleanup ----
6523
6524    #[tokio::test]
6525    async fn shutdown_cleanup() {
6526        let data = vec![0xAB; 16384];
6527        let meta = make_test_torrent(&data, 16384);
6528        let storage = make_storage(&data, 16384);
6529        let config = test_config();
6530
6531        let (atx, amask) = test_alert_channel();
6532        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6533        let handle = TorrentHandle::from_torrent(
6534            meta,
6535            irontide_core::TorrentVersion::V1Only,
6536            None,
6537            dh,
6538            dm,
6539            config,
6540            test_dht_rx(),
6541            test_dht_rx(),
6542            None,
6543            None,
6544            crate::slot_tuner::SlotTuner::disabled(4),
6545            atx,
6546            amask,
6547            None,
6548            None,
6549            test_ban_manager(),
6550            test_ip_filter(),
6551            Arc::new(Vec::new()),
6552            None,
6553            None,
6554            Arc::new(crate::transport::NetworkFactory::tokio()),
6555            None, // M96: hash_pool
6556            Arc::new(crate::stats::SessionCounters::new()),
6557        )
6558        .await
6559        .unwrap();
6560
6561        handle.shutdown().await.unwrap();
6562
6563        // After shutdown, stats should fail (channel closed)
6564        tokio::time::sleep(Duration::from_millis(50)).await;
6565        let result = handle.stats().await;
6566        assert!(result.is_err());
6567    }
6568
6569    // ---- Test 7: Duplicate add_peers ignored ----
6570
6571    #[tokio::test]
6572    async fn duplicate_peers_ignored() {
6573        let data = vec![0xAB; 16384];
6574        let meta = make_test_torrent(&data, 16384);
6575        let storage = make_storage(&data, 16384);
6576        let config = test_config();
6577
6578        let (atx, amask) = test_alert_channel();
6579        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6580        let handle = TorrentHandle::from_torrent(
6581            meta,
6582            irontide_core::TorrentVersion::V1Only,
6583            None,
6584            dh,
6585            dm,
6586            config,
6587            test_dht_rx(),
6588            test_dht_rx(),
6589            None,
6590            None,
6591            crate::slot_tuner::SlotTuner::disabled(4),
6592            atx,
6593            amask,
6594            None,
6595            None,
6596            test_ban_manager(),
6597            test_ip_filter(),
6598            Arc::new(Vec::new()),
6599            None,
6600            None,
6601            Arc::new(crate::transport::NetworkFactory::tokio()),
6602            None, // M96: hash_pool
6603            Arc::new(crate::stats::SessionCounters::new()),
6604        )
6605        .await
6606        .unwrap();
6607
6608        // Bind a listener so the connection succeeds and the peer stays connected
6609        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6610        let addr = listener.local_addr().unwrap();
6611        handle
6612            .add_peers(vec![addr, addr, addr], PeerSource::Tracker)
6613            .await
6614            .unwrap();
6615
6616        tokio::time::sleep(Duration::from_millis(100)).await;
6617        let stats = handle.stats().await.unwrap();
6618        // Only one unique peer should be known (available or connecting)
6619        assert!(
6620            stats.peers_available + stats.peers_connected <= 1,
6621            "expected at most 1 unique peer, got available={}, connected={}",
6622            stats.peers_available,
6623            stats.peers_connected
6624        );
6625
6626        handle.shutdown().await.unwrap();
6627    }
6628
6629    // ---- Test 8: Multiple handles (Clone) share same actor ----
6630
6631    #[tokio::test]
6632    async fn cloned_handle_shares_actor() {
6633        let data = vec![0xAB; 16384];
6634        let meta = make_test_torrent(&data, 16384);
6635        let storage = make_storage(&data, 16384);
6636        let config = test_config();
6637
6638        let (atx, amask) = test_alert_channel();
6639        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6640        let handle = TorrentHandle::from_torrent(
6641            meta,
6642            irontide_core::TorrentVersion::V1Only,
6643            None,
6644            dh,
6645            dm,
6646            config,
6647            test_dht_rx(),
6648            test_dht_rx(),
6649            None,
6650            None,
6651            crate::slot_tuner::SlotTuner::disabled(4),
6652            atx,
6653            amask,
6654            None,
6655            None,
6656            test_ban_manager(),
6657            test_ip_filter(),
6658            Arc::new(Vec::new()),
6659            None,
6660            None,
6661            Arc::new(crate::transport::NetworkFactory::tokio()),
6662            None, // M96: hash_pool
6663            Arc::new(crate::stats::SessionCounters::new()),
6664        )
6665        .await
6666        .unwrap();
6667        let handle2 = handle.clone();
6668
6669        // Bind a listener so the connection succeeds and the peer stays connected
6670        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6671        let peer_addr = listener.local_addr().unwrap();
6672
6673        // Add peers through one handle
6674        handle
6675            .add_peers(vec![peer_addr], PeerSource::Tracker)
6676            .await
6677            .unwrap();
6678
6679        tokio::time::sleep(Duration::from_millis(100)).await;
6680
6681        // Read stats through the other — peer may be available or connecting
6682        let stats = handle2.stats().await.unwrap();
6683        assert!(
6684            stats.peers_available + stats.peers_connected >= 1,
6685            "expected at least 1 peer known, got available={}, connected={}",
6686            stats.peers_available,
6687            stats.peers_connected
6688        );
6689
6690        handle.shutdown().await.unwrap();
6691    }
6692
6693    // ---- Test 9: Peer connection and disconnect via listener ----
6694
6695    #[tokio::test]
6696    async fn peer_connect_and_disconnect_via_listener() {
6697        let data = vec![0xAB; 16384];
6698        let meta = make_test_torrent(&data, 16384);
6699        let info_hash = meta.info_hash;
6700        let storage = make_storage(&data, 16384);
6701
6702        // Bind a listener on a specific port so we can connect to it
6703        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6704        let listen_addr = listener.local_addr().unwrap();
6705
6706        let config = TorrentConfig {
6707            listen_port: listen_addr.port(),
6708            ..test_config()
6709        };
6710
6711        // Drop the pre-bound listener before from_torrent binds
6712        drop(listener);
6713
6714        let (atx, amask) = test_alert_channel();
6715        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6716        let handle = TorrentHandle::from_torrent(
6717            meta,
6718            irontide_core::TorrentVersion::V1Only,
6719            None,
6720            dh,
6721            dm,
6722            config,
6723            test_dht_rx(),
6724            test_dht_rx(),
6725            None,
6726            None,
6727            crate::slot_tuner::SlotTuner::disabled(4),
6728            atx,
6729            amask,
6730            None,
6731            None,
6732            test_ban_manager(),
6733            test_ip_filter(),
6734            Arc::new(Vec::new()),
6735            None,
6736            None,
6737            Arc::new(crate::transport::NetworkFactory::tokio()),
6738            None, // M96: hash_pool
6739            Arc::new(crate::stats::SessionCounters::new()),
6740        )
6741        .await
6742        .unwrap();
6743
6744        // Give the actor time to start
6745        tokio::time::sleep(Duration::from_millis(50)).await;
6746
6747        // Connect a mock peer
6748        let mut stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6749
6750        // Perform handshake
6751        let remote_id = Id20::from_hex("1111111111111111111111111111111111111111").unwrap();
6752        let remote_hs = Handshake::new(info_hash, remote_id);
6753        stream.write_all(&remote_hs.to_bytes()).await.unwrap();
6754        stream.flush().await.unwrap();
6755
6756        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6757        stream.read_exact(&mut hs_buf).await.unwrap();
6758        let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
6759        assert_eq!(their_hs.info_hash, info_hash);
6760
6761        // Give time for peer to be registered
6762        tokio::time::sleep(Duration::from_millis(100)).await;
6763
6764        let stats = handle.stats().await.unwrap();
6765        assert_eq!(stats.peers_connected, 1);
6766
6767        // Drop the connection
6768        drop(stream);
6769
6770        // Wait for disconnect event
6771        tokio::time::sleep(Duration::from_millis(200)).await;
6772
6773        let stats = handle.stats().await.unwrap();
6774        assert_eq!(stats.peers_connected, 0);
6775
6776        handle.shutdown().await.unwrap();
6777    }
6778
6779    // ---- Test 10: Piece download and verification via injected events ----
6780    //
6781    // We test the full flow: connect a mock peer that sends bitfield, unchoke,
6782    // then responds to requests with correct piece data.
6783
6784    #[tokio::test]
6785    async fn piece_download_and_verify() {
6786        // Create a 1-piece torrent with 16384 bytes (exactly one chunk)
6787        let data = vec![0xCDu8; 16384];
6788        let meta = make_test_torrent(&data, 16384);
6789        let info_hash = meta.info_hash;
6790        let storage = make_storage(&data, 16384);
6791
6792        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6793        let listen_addr = listener.local_addr().unwrap();
6794        drop(listener);
6795
6796        let config = TorrentConfig {
6797            listen_port: listen_addr.port(),
6798            ..test_config()
6799        };
6800
6801        let (atx, amask) = test_alert_channel();
6802        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6803        let handle = TorrentHandle::from_torrent(
6804            meta,
6805            irontide_core::TorrentVersion::V1Only,
6806            None,
6807            dh,
6808            dm,
6809            config,
6810            test_dht_rx(),
6811            test_dht_rx(),
6812            None,
6813            None,
6814            crate::slot_tuner::SlotTuner::disabled(4),
6815            atx,
6816            amask,
6817            None,
6818            None,
6819            test_ban_manager(),
6820            test_ip_filter(),
6821            Arc::new(Vec::new()),
6822            None,
6823            None,
6824            Arc::new(crate::transport::NetworkFactory::tokio()),
6825            None, // M96: hash_pool
6826            Arc::new(crate::stats::SessionCounters::new()),
6827        )
6828        .await
6829        .unwrap();
6830
6831        tokio::time::sleep(Duration::from_millis(50)).await;
6832
6833        // Connect mock peer
6834        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6835        let remote_id = Id20::from_hex("2222222222222222222222222222222222222222").unwrap();
6836
6837        // Run mock seeder in a task
6838        let mock_data = data.clone();
6839        let mock_task = tokio::spawn(async move {
6840            let (reader, writer) = tokio::io::split(stream);
6841            let mut reader = reader;
6842            let mut writer = writer;
6843
6844            // Handshake
6845            let hs = Handshake::new(info_hash, remote_id);
6846            writer.write_all(&hs.to_bytes()).await.unwrap();
6847            writer.flush().await.unwrap();
6848
6849            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6850            reader.read_exact(&mut hs_buf).await.unwrap();
6851
6852            // Switch to framed
6853            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6854            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6855
6856            // Read ext handshake from the torrent actor's peer
6857            let _msg = framed_read.next().await;
6858
6859            // Send ext handshake back
6860            let ext_hs = ExtHandshake::new();
6861            let payload = ext_hs.to_bytes().unwrap();
6862            framed_write
6863                .send(Message::Extended { ext_id: 0, payload })
6864                .await
6865                .unwrap();
6866
6867            // Send bitfield (all pieces = piece 0 set)
6868            let mut bf = Bitfield::new(1);
6869            bf.set(0);
6870            framed_write
6871                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6872                .await
6873                .unwrap();
6874
6875            // Send Unchoke
6876            framed_write.send(Message::Unchoke).await.unwrap();
6877
6878            // Wait for requests and respond with piece data
6879            while let Some(Ok(msg)) = framed_read.next().await {
6880                if let Message::Request {
6881                    index,
6882                    begin,
6883                    length,
6884                } = msg
6885                {
6886                    let start = begin as usize;
6887                    let end = start + length as usize;
6888                    let piece_data = &mock_data[start..end];
6889                    framed_write
6890                        .send(Message::Piece {
6891                            index,
6892                            begin,
6893                            data_0: Bytes::copy_from_slice(piece_data),
6894                            data_1: Bytes::new(),
6895                        })
6896                        .await
6897                        .unwrap();
6898                }
6899            }
6900        });
6901
6902        // Wait for the download to complete
6903        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6904        loop {
6905            tokio::time::sleep(Duration::from_millis(100)).await;
6906            let stats = handle.stats().await.unwrap();
6907            if stats.state == TorrentState::Seeding {
6908                assert_eq!(stats.pieces_have, 1);
6909                assert_eq!(stats.pieces_total, 1);
6910                break;
6911            }
6912            if tokio::time::Instant::now() > deadline {
6913                let stats = handle.stats().await.unwrap();
6914                panic!(
6915                    "download did not complete within 5s, state={:?}, have={}/{}",
6916                    stats.state, stats.pieces_have, stats.pieces_total
6917                );
6918            }
6919        }
6920
6921        handle.shutdown().await.unwrap();
6922        mock_task.abort();
6923    }
6924
6925    // ---- Test 11: Failed piece verification re-requests ----
6926
6927    #[tokio::test]
6928    async fn failed_piece_verification() {
6929        // Create a 1-piece torrent
6930        let data = vec![0xEEu8; 16384];
6931        let meta = make_test_torrent(&data, 16384);
6932        let info_hash = meta.info_hash;
6933        let storage = make_storage(&data, 16384);
6934
6935        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6936        let listen_addr = listener.local_addr().unwrap();
6937        drop(listener);
6938
6939        let config = TorrentConfig {
6940            listen_port: listen_addr.port(),
6941            ..test_config()
6942        };
6943
6944        let (atx, amask) = test_alert_channel();
6945        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6946        let handle = TorrentHandle::from_torrent(
6947            meta,
6948            irontide_core::TorrentVersion::V1Only,
6949            None,
6950            dh,
6951            dm,
6952            config,
6953            test_dht_rx(),
6954            test_dht_rx(),
6955            None,
6956            None,
6957            crate::slot_tuner::SlotTuner::disabled(4),
6958            atx,
6959            amask,
6960            None,
6961            None,
6962            test_ban_manager(),
6963            test_ip_filter(),
6964            Arc::new(Vec::new()),
6965            None,
6966            None,
6967            Arc::new(crate::transport::NetworkFactory::tokio()),
6968            None, // M96: hash_pool
6969            Arc::new(crate::stats::SessionCounters::new()),
6970        )
6971        .await
6972        .unwrap();
6973
6974        tokio::time::sleep(Duration::from_millis(50)).await;
6975
6976        // Connect mock peer that first sends bad data, then correct data
6977        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6978        let remote_id = Id20::from_hex("3333333333333333333333333333333333333333").unwrap();
6979
6980        let correct_data = data.clone();
6981        let mock_task = tokio::spawn(async move {
6982            let (reader, writer) = tokio::io::split(stream);
6983
6984            // Handshake
6985            let mut writer = writer;
6986            let mut reader = reader;
6987            let hs = Handshake::new(info_hash, remote_id);
6988            writer.write_all(&hs.to_bytes()).await.unwrap();
6989            writer.flush().await.unwrap();
6990
6991            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6992            reader.read_exact(&mut hs_buf).await.unwrap();
6993
6994            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6995            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6996
6997            // Read ext handshake
6998            let _msg = framed_read.next().await;
6999
7000            // Send ext handshake
7001            let ext_hs = ExtHandshake::new();
7002            let payload = ext_hs.to_bytes().unwrap();
7003            framed_write
7004                .send(Message::Extended { ext_id: 0, payload })
7005                .await
7006                .unwrap();
7007
7008            // Bitfield: have piece 0
7009            let mut bf = Bitfield::new(1);
7010            bf.set(0);
7011            framed_write
7012                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7013                .await
7014                .unwrap();
7015
7016            // Unchoke
7017            framed_write.send(Message::Unchoke).await.unwrap();
7018
7019            let mut request_count = 0u32;
7020            while let Some(Ok(msg)) = framed_read.next().await {
7021                if let Message::Request {
7022                    index,
7023                    begin,
7024                    length,
7025                } = msg
7026                {
7027                    request_count += 1;
7028                    let piece_data = if request_count <= 1 {
7029                        // First request: send bad data
7030                        vec![0xFF; length as usize]
7031                    } else {
7032                        // Subsequent: send correct data
7033                        let start = begin as usize;
7034                        let end = start + length as usize;
7035                        correct_data[start..end].to_vec()
7036                    };
7037                    framed_write
7038                        .send(Message::Piece {
7039                            index,
7040                            begin,
7041                            data_0: Bytes::from(piece_data),
7042                            data_1: Bytes::new(),
7043                        })
7044                        .await
7045                        .unwrap();
7046                }
7047            }
7048        });
7049
7050        // Wait for completion (should eventually succeed after retry)
7051        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7052        loop {
7053            tokio::time::sleep(Duration::from_millis(100)).await;
7054            let stats = handle.stats().await.unwrap();
7055            if stats.state == TorrentState::Seeding {
7056                assert_eq!(stats.pieces_have, 1);
7057                break;
7058            }
7059            if tokio::time::Instant::now() > deadline {
7060                let stats = handle.stats().await.unwrap();
7061                panic!(
7062                    "download did not complete after retry within 5s, state={:?}, have={}",
7063                    stats.state, stats.pieces_have,
7064                );
7065            }
7066        }
7067
7068        handle.shutdown().await.unwrap();
7069        mock_task.abort();
7070    }
7071
7072    // ---- Test 12: Complete state transitions after all pieces ----
7073
7074    #[tokio::test]
7075    async fn complete_transitions_state() {
7076        // 2-piece torrent, each 16384 bytes (one chunk each)
7077        let data = vec![0xBBu8; 32768];
7078        let meta = make_test_torrent(&data, 16384);
7079        let info_hash = meta.info_hash;
7080        let storage = make_storage(&data, 16384);
7081
7082        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7083        let listen_addr = listener.local_addr().unwrap();
7084        drop(listener);
7085
7086        let config = TorrentConfig {
7087            listen_port: listen_addr.port(),
7088            ..test_config()
7089        };
7090
7091        let (atx, amask) = test_alert_channel();
7092        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7093        let handle = TorrentHandle::from_torrent(
7094            meta,
7095            irontide_core::TorrentVersion::V1Only,
7096            None,
7097            dh,
7098            dm,
7099            config,
7100            test_dht_rx(),
7101            test_dht_rx(),
7102            None,
7103            None,
7104            crate::slot_tuner::SlotTuner::disabled(4),
7105            atx,
7106            amask,
7107            None,
7108            None,
7109            test_ban_manager(),
7110            test_ip_filter(),
7111            Arc::new(Vec::new()),
7112            None,
7113            None,
7114            Arc::new(crate::transport::NetworkFactory::tokio()),
7115            None, // M96: hash_pool
7116            Arc::new(crate::stats::SessionCounters::new()),
7117        )
7118        .await
7119        .unwrap();
7120
7121        tokio::time::sleep(Duration::from_millis(50)).await;
7122
7123        // Mock seeder with all 2 pieces
7124        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7125        let remote_id = Id20::from_hex("4444444444444444444444444444444444444444").unwrap();
7126
7127        let mock_data = data.clone();
7128        let mock_task = tokio::spawn(async move {
7129            let (reader, writer) = tokio::io::split(stream);
7130            let mut writer = writer;
7131            let mut reader = reader;
7132
7133            let hs = Handshake::new(info_hash, remote_id);
7134            writer.write_all(&hs.to_bytes()).await.unwrap();
7135            writer.flush().await.unwrap();
7136
7137            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7138            reader.read_exact(&mut hs_buf).await.unwrap();
7139
7140            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7141            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7142
7143            // Read ext handshake
7144            let _msg = framed_read.next().await;
7145
7146            // Send ext handshake
7147            let ext_hs = ExtHandshake::new();
7148            let payload = ext_hs.to_bytes().unwrap();
7149            framed_write
7150                .send(Message::Extended { ext_id: 0, payload })
7151                .await
7152                .unwrap();
7153
7154            // Bitfield: have both pieces
7155            let mut bf = Bitfield::new(2);
7156            bf.set(0);
7157            bf.set(1);
7158            framed_write
7159                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7160                .await
7161                .unwrap();
7162
7163            framed_write.send(Message::Unchoke).await.unwrap();
7164
7165            while let Some(Ok(msg)) = framed_read.next().await {
7166                if let Message::Request {
7167                    index,
7168                    begin,
7169                    length,
7170                } = msg
7171                {
7172                    let abs_start = (index as usize * 16384) + begin as usize;
7173                    let abs_end = abs_start + length as usize;
7174                    let piece_data = &mock_data[abs_start..abs_end];
7175                    framed_write
7176                        .send(Message::Piece {
7177                            index,
7178                            begin,
7179                            data_0: Bytes::copy_from_slice(piece_data),
7180                            data_1: Bytes::new(),
7181                        })
7182                        .await
7183                        .unwrap();
7184                }
7185            }
7186        });
7187
7188        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7189        loop {
7190            tokio::time::sleep(Duration::from_millis(100)).await;
7191            let stats = handle.stats().await.unwrap();
7192            if stats.state == TorrentState::Seeding {
7193                assert_eq!(stats.pieces_have, 2);
7194                assert_eq!(stats.pieces_total, 2);
7195                break;
7196            }
7197            if tokio::time::Instant::now() > deadline {
7198                let stats = handle.stats().await.unwrap();
7199                panic!(
7200                    "expected Complete, got {:?}, have={}/{}",
7201                    stats.state, stats.pieces_have, stats.pieces_total
7202                );
7203            }
7204        }
7205
7206        handle.shutdown().await.unwrap();
7207        mock_task.abort();
7208    }
7209
7210    // ---- Test 13: Multiple pieces with multi-chunk pieces ----
7211
7212    #[tokio::test]
7213    async fn multi_chunk_piece_download() {
7214        // 1 piece of 32768 bytes = 2 chunks of 16384 each
7215        let data = vec![0xAAu8; 32768];
7216        let meta = make_test_torrent(&data, 32768);
7217        let info_hash = meta.info_hash;
7218        let storage = make_storage(&data, 32768);
7219
7220        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7221        let listen_addr = listener.local_addr().unwrap();
7222        drop(listener);
7223
7224        let config = TorrentConfig {
7225            listen_port: listen_addr.port(),
7226            ..test_config()
7227        };
7228
7229        let (atx, amask) = test_alert_channel();
7230        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7231        let handle = TorrentHandle::from_torrent(
7232            meta,
7233            irontide_core::TorrentVersion::V1Only,
7234            None,
7235            dh,
7236            dm,
7237            config,
7238            test_dht_rx(),
7239            test_dht_rx(),
7240            None,
7241            None,
7242            crate::slot_tuner::SlotTuner::disabled(4),
7243            atx,
7244            amask,
7245            None,
7246            None,
7247            test_ban_manager(),
7248            test_ip_filter(),
7249            Arc::new(Vec::new()),
7250            None,
7251            None,
7252            Arc::new(crate::transport::NetworkFactory::tokio()),
7253            None, // M96: hash_pool
7254            Arc::new(crate::stats::SessionCounters::new()),
7255        )
7256        .await
7257        .unwrap();
7258
7259        tokio::time::sleep(Duration::from_millis(50)).await;
7260
7261        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7262        let remote_id = Id20::from_hex("5555555555555555555555555555555555555555").unwrap();
7263
7264        let mock_data = data.clone();
7265        let mock_task = tokio::spawn(async move {
7266            let (reader, writer) = tokio::io::split(stream);
7267            let mut writer = writer;
7268            let mut reader = reader;
7269
7270            let hs = Handshake::new(info_hash, remote_id);
7271            writer.write_all(&hs.to_bytes()).await.unwrap();
7272            writer.flush().await.unwrap();
7273
7274            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7275            reader.read_exact(&mut hs_buf).await.unwrap();
7276
7277            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7278            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7279
7280            let _msg = framed_read.next().await;
7281
7282            let ext_hs = ExtHandshake::new();
7283            let payload = ext_hs.to_bytes().unwrap();
7284            framed_write
7285                .send(Message::Extended { ext_id: 0, payload })
7286                .await
7287                .unwrap();
7288
7289            let mut bf = Bitfield::new(1);
7290            bf.set(0);
7291            framed_write
7292                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7293                .await
7294                .unwrap();
7295
7296            framed_write.send(Message::Unchoke).await.unwrap();
7297
7298            while let Some(Ok(msg)) = framed_read.next().await {
7299                if let Message::Request {
7300                    index: _,
7301                    begin,
7302                    length,
7303                } = msg
7304                {
7305                    let start = begin as usize;
7306                    let end = start + length as usize;
7307                    framed_write
7308                        .send(Message::Piece {
7309                            index: 0,
7310                            begin,
7311                            data_0: Bytes::copy_from_slice(&mock_data[start..end]),
7312                            data_1: Bytes::new(),
7313                        })
7314                        .await
7315                        .unwrap();
7316                }
7317            }
7318        });
7319
7320        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7321        loop {
7322            tokio::time::sleep(Duration::from_millis(100)).await;
7323            let stats = handle.stats().await.unwrap();
7324            if stats.state == TorrentState::Seeding {
7325                assert_eq!(stats.pieces_have, 1);
7326                break;
7327            }
7328            assert!(
7329                tokio::time::Instant::now() <= deadline,
7330                "multi-chunk download did not complete within 5s"
7331            );
7332        }
7333
7334        handle.shutdown().await.unwrap();
7335        mock_task.abort();
7336    }
7337
7338    // ---- Test 14: Seeder/Leecher integration with two actors ----
7339
7340    #[tokio::test]
7341    async fn seeder_leecher_integration() {
7342        // Seeder has all data, leecher has none. Connect them via TCP.
7343        let data = vec![0xDDu8; 32768]; // 32 KiB, 2 pieces of 16384
7344        let piece_length = 16384u64;
7345        let meta = make_test_torrent(&data, piece_length);
7346        let info_hash = meta.info_hash;
7347
7348        // Seeder: storage pre-filled
7349        let seeder_storage = make_seeded_storage(&data, piece_length);
7350
7351        // For the seeder, we need a from_torrent variant that starts in Complete state
7352        // but still serves pieces. Since our actor starts in Downloading, the seeder
7353        // will just be a mock that accepts and serves.
7354
7355        // Use a mock seeder approach instead (manual protocol handling):
7356        let seeder_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7357        let seeder_addr = seeder_listener.local_addr().unwrap();
7358
7359        let seeder_task = tokio::spawn(async move {
7360            let (stream, _addr) = seeder_listener.accept().await.unwrap();
7361            let (reader, writer) = tokio::io::split(stream);
7362            let mut writer = writer;
7363            let mut reader = reader;
7364
7365            // Handshake
7366            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7367            reader.read_exact(&mut hs_buf).await.unwrap();
7368            let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
7369            assert_eq!(their_hs.info_hash, info_hash);
7370
7371            let hs = Handshake::new(info_hash, PeerId::generate().0);
7372            writer.write_all(&hs.to_bytes()).await.unwrap();
7373            writer.flush().await.unwrap();
7374
7375            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7376            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7377
7378            // Read ext handshake
7379            let _msg = framed_read.next().await;
7380
7381            // Send ext handshake
7382            let ext_hs = ExtHandshake::new();
7383            let payload = ext_hs.to_bytes().unwrap();
7384            framed_write
7385                .send(Message::Extended { ext_id: 0, payload })
7386                .await
7387                .unwrap();
7388
7389            // Send bitfield (all pieces)
7390            let mut bf = Bitfield::new(2);
7391            bf.set(0);
7392            bf.set(1);
7393            framed_write
7394                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7395                .await
7396                .unwrap();
7397
7398            // Unchoke
7399            framed_write.send(Message::Unchoke).await.unwrap();
7400
7401            // Serve requests
7402            while let Some(Ok(msg)) = framed_read.next().await {
7403                if let Message::Request {
7404                    index,
7405                    begin,
7406                    length,
7407                } = msg
7408                {
7409                    let piece_data = seeder_storage.read_chunk(index, begin, length).unwrap();
7410                    framed_write
7411                        .send(Message::Piece {
7412                            index,
7413                            begin,
7414                            data_0: Bytes::from(piece_data),
7415                            data_1: Bytes::new(),
7416                        })
7417                        .await
7418                        .unwrap();
7419                }
7420            }
7421        });
7422
7423        // Leecher: empty storage
7424        let leecher_storage = make_storage(&data, piece_length);
7425        let leecher_meta = make_test_torrent(&data, piece_length);
7426
7427        let leecher_config = test_config();
7428        let (latx, lamask) = test_alert_channel();
7429        let (ldh, ldm, _ldj) = test_register_disk(leecher_meta.info_hash, leecher_storage).await;
7430        let leecher = TorrentHandle::from_torrent(
7431            leecher_meta,
7432            irontide_core::TorrentVersion::V1Only,
7433            None,
7434            ldh,
7435            ldm,
7436            leecher_config,
7437            test_dht_rx(),
7438            test_dht_rx(),
7439            None,
7440            None,
7441            crate::slot_tuner::SlotTuner::disabled(4),
7442            latx,
7443            lamask,
7444            None,
7445            None,
7446            test_ban_manager(),
7447            test_ip_filter(),
7448            Arc::new(Vec::new()),
7449            None,
7450            None,
7451            Arc::new(crate::transport::NetworkFactory::tokio()),
7452            None, // M96: hash_pool
7453            Arc::new(crate::stats::SessionCounters::new()),
7454        )
7455        .await
7456        .unwrap();
7457
7458        // Add seeder as a peer
7459        leecher
7460            .add_peers(vec![seeder_addr], PeerSource::Tracker)
7461            .await
7462            .unwrap();
7463
7464        // Give the connect interval time to fire (it ticks every 5s).
7465        // The actor's try_connect_peers runs on the timer, and also immediately
7466        // when peers are added via AddPeers command. Wait up to 10 seconds.
7467        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
7468        loop {
7469            tokio::time::sleep(Duration::from_millis(200)).await;
7470            let stats = leecher.stats().await.unwrap();
7471            if stats.state == TorrentState::Seeding {
7472                assert_eq!(stats.pieces_have, 2);
7473                assert_eq!(stats.pieces_total, 2);
7474                break;
7475            }
7476            if tokio::time::Instant::now() > deadline {
7477                let stats = leecher.stats().await.unwrap();
7478                panic!(
7479                    "seeder/leecher: leecher did not complete, state={:?}, have={}/{}, connected={}, available={}",
7480                    stats.state,
7481                    stats.pieces_have,
7482                    stats.pieces_total,
7483                    stats.peers_connected,
7484                    stats.peers_available,
7485                );
7486            }
7487        }
7488
7489        leecher.shutdown().await.unwrap();
7490        seeder_task.abort();
7491    }
7492
7493    // ---- Test 15: Magnet stats ----
7494
7495    #[tokio::test]
7496    async fn magnet_initial_stats() {
7497        let magnet = Magnet {
7498            info_hashes: irontide_core::InfoHashes::v1_only(
7499                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
7500            ),
7501            display_name: Some("magnet test".into()),
7502            trackers: vec![],
7503            peers: vec![],
7504            selected_files: None,
7505        };
7506
7507        let (atx, amask) = test_alert_channel();
7508        let (dm, _dj) = test_disk_manager();
7509        let handle = TorrentHandle::from_magnet(
7510            magnet,
7511            dm,
7512            test_config(),
7513            test_dht_rx(),
7514            test_dht_rx(),
7515            None,
7516            None,
7517            crate::slot_tuner::SlotTuner::disabled(4),
7518            atx,
7519            amask,
7520            None,
7521            None,
7522            test_ban_manager(),
7523            test_ip_filter(),
7524            Arc::new(Vec::new()),
7525            None,
7526            None,
7527            Arc::new(crate::transport::NetworkFactory::tokio()),
7528            None, // M96: hash_pool
7529            Arc::new(crate::stats::SessionCounters::new()),
7530        )
7531        .await
7532        .unwrap();
7533
7534        let stats = handle.stats().await.unwrap();
7535        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7536        assert_eq!(stats.pieces_total, 0);
7537        assert_eq!(stats.pieces_have, 0);
7538        assert_eq!(stats.downloaded, 0);
7539        assert_eq!(stats.uploaded, 0);
7540        assert_eq!(stats.peers_connected, 0);
7541        assert_eq!(stats.peers_available, 0);
7542
7543        handle.shutdown().await.unwrap();
7544    }
7545
7546    // ---- Test 16: Tracker manager is populated from torrent metadata ----
7547
7548    #[tokio::test]
7549    async fn tracker_populated_from_metadata() {
7550        use serde::Serialize;
7551
7552        #[derive(Serialize)]
7553        struct Info<'a> {
7554            length: u64,
7555            name: &'a str,
7556            #[serde(rename = "piece length")]
7557            piece_length: u64,
7558            #[serde(with = "serde_bytes")]
7559            pieces: &'a [u8],
7560        }
7561
7562        #[derive(Serialize)]
7563        struct Torrent<'a> {
7564            announce: &'a str,
7565            info: Info<'a>,
7566        }
7567
7568        let data = vec![0xAB; 16384];
7569        let hash = irontide_core::sha1(&data);
7570        let mut pieces = Vec::new();
7571        pieces.extend_from_slice(hash.as_bytes());
7572
7573        let t = Torrent {
7574            announce: "http://tracker.example.com:8080/announce",
7575            info: Info {
7576                length: data.len() as u64,
7577                name: "test",
7578                piece_length: 16384,
7579                pieces: &pieces,
7580            },
7581        };
7582
7583        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7584        let meta = torrent_from_bytes(&bytes).unwrap();
7585        assert!(meta.announce.is_some());
7586
7587        let storage = make_storage(&data, 16384);
7588        let config = test_config();
7589
7590        // The torrent should start and announce to tracker (which will fail since
7591        // the tracker doesn't exist, but that's fine — failures are non-fatal).
7592        let (atx, amask) = test_alert_channel();
7593        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7594        let handle = TorrentHandle::from_torrent(
7595            meta,
7596            irontide_core::TorrentVersion::V1Only,
7597            None,
7598            dh,
7599            dm,
7600            config,
7601            test_dht_rx(),
7602            test_dht_rx(),
7603            None,
7604            None,
7605            crate::slot_tuner::SlotTuner::disabled(4),
7606            atx,
7607            amask,
7608            None,
7609            None,
7610            test_ban_manager(),
7611            test_ip_filter(),
7612            Arc::new(Vec::new()),
7613            None,
7614            None,
7615            Arc::new(crate::transport::NetworkFactory::tokio()),
7616            None, // M96: hash_pool
7617            Arc::new(crate::stats::SessionCounters::new()),
7618        )
7619        .await
7620        .unwrap();
7621
7622        let stats = handle.stats().await.unwrap();
7623        assert_eq!(stats.state, TorrentState::Downloading);
7624
7625        handle.shutdown().await.unwrap();
7626    }
7627
7628    // ---- Test 17: Private torrent with DHT=None works ----
7629
7630    #[tokio::test]
7631    async fn private_torrent_no_dht_field() {
7632        use serde::Serialize;
7633
7634        #[derive(Serialize)]
7635        struct Info<'a> {
7636            length: u64,
7637            name: &'a str,
7638            #[serde(rename = "piece length")]
7639            piece_length: u64,
7640            #[serde(with = "serde_bytes")]
7641            pieces: &'a [u8],
7642            private: i64,
7643        }
7644
7645        #[derive(Serialize)]
7646        struct Torrent<'a> {
7647            announce: &'a str,
7648            info: Info<'a>,
7649        }
7650
7651        let data = vec![0xAB; 16384];
7652        let hash = irontide_core::sha1(&data);
7653        let mut pieces = Vec::new();
7654        pieces.extend_from_slice(hash.as_bytes());
7655
7656        let t = Torrent {
7657            announce: "http://private-tracker.example.com/announce",
7658            info: Info {
7659                length: data.len() as u64,
7660                name: "private_test",
7661                piece_length: 16384,
7662                pieces: &pieces,
7663                private: 1,
7664            },
7665        };
7666
7667        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7668        let meta = torrent_from_bytes(&bytes).unwrap();
7669        assert_eq!(meta.info.private, Some(1));
7670
7671        let storage = make_storage(&data, 16384);
7672        let config = test_config();
7673
7674        let (atx, amask) = test_alert_channel();
7675        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7676        let handle = TorrentHandle::from_torrent(
7677            meta,
7678            irontide_core::TorrentVersion::V1Only,
7679            None,
7680            dh,
7681            dm,
7682            config,
7683            test_dht_rx(),
7684            test_dht_rx(),
7685            None,
7686            None,
7687            crate::slot_tuner::SlotTuner::disabled(4),
7688            atx,
7689            amask,
7690            None,
7691            None,
7692            test_ban_manager(),
7693            test_ip_filter(),
7694            Arc::new(Vec::new()),
7695            None,
7696            None,
7697            Arc::new(crate::transport::NetworkFactory::tokio()),
7698            None, // M96: hash_pool
7699            Arc::new(crate::stats::SessionCounters::new()),
7700        )
7701        .await
7702        .unwrap();
7703
7704        let stats = handle.stats().await.unwrap();
7705        assert_eq!(stats.state, TorrentState::Downloading);
7706
7707        handle.shutdown().await.unwrap();
7708    }
7709
7710    // ---- Test 18: Magnet defers tracker announce ----
7711
7712    #[tokio::test]
7713    async fn magnet_no_tracker_before_metadata() {
7714        let magnet = Magnet {
7715            info_hashes: irontide_core::InfoHashes::v1_only(
7716                Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
7717            ),
7718            display_name: Some("magnet test".into()),
7719            trackers: vec![],
7720            peers: vec![],
7721            selected_files: None,
7722        };
7723
7724        let (atx, amask) = test_alert_channel();
7725        let (dm, _dj) = test_disk_manager();
7726        let handle = TorrentHandle::from_magnet(
7727            magnet,
7728            dm,
7729            test_config(),
7730            test_dht_rx(),
7731            test_dht_rx(),
7732            None,
7733            None,
7734            crate::slot_tuner::SlotTuner::disabled(4),
7735            atx,
7736            amask,
7737            None,
7738            None,
7739            test_ban_manager(),
7740            test_ip_filter(),
7741            Arc::new(Vec::new()),
7742            None,
7743            None,
7744            Arc::new(crate::transport::NetworkFactory::tokio()),
7745            None, // M96: hash_pool
7746            Arc::new(crate::stats::SessionCounters::new()),
7747        )
7748        .await
7749        .unwrap();
7750
7751        let stats = handle.stats().await.unwrap();
7752        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7753
7754        // With no trackers configured, no announces happen regardless of state.
7755        // Note: tracker announces ARE now allowed during FetchingMetadata for
7756        // magnets with &tr= URLs (needed to discover peers before metadata).
7757        tokio::time::sleep(Duration::from_millis(50)).await;
7758
7759        handle.shutdown().await.unwrap();
7760    }
7761
7762    // ---- Test 19: Pause and resume ----
7763
7764    #[tokio::test]
7765    async fn pause_and_resume() {
7766        let data = vec![0xEEu8; 32768];
7767        let meta = make_test_torrent(&data, 16384);
7768        let storage = make_storage(&data, 16384);
7769        let config = test_config();
7770        let (atx, amask) = test_alert_channel();
7771        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7772        let handle = TorrentHandle::from_torrent(
7773            meta,
7774            irontide_core::TorrentVersion::V1Only,
7775            None,
7776            dh,
7777            dm,
7778            config,
7779            test_dht_rx(),
7780            test_dht_rx(),
7781            None,
7782            None,
7783            crate::slot_tuner::SlotTuner::disabled(4),
7784            atx,
7785            amask,
7786            None,
7787            None,
7788            test_ban_manager(),
7789            test_ip_filter(),
7790            Arc::new(Vec::new()),
7791            None,
7792            None,
7793            Arc::new(crate::transport::NetworkFactory::tokio()),
7794            None, // M96: hash_pool
7795            Arc::new(crate::stats::SessionCounters::new()),
7796        )
7797        .await
7798        .unwrap();
7799
7800        let stats = handle.stats().await.unwrap();
7801        assert_eq!(stats.state, TorrentState::Downloading);
7802
7803        handle.pause().await.unwrap();
7804        tokio::time::sleep(Duration::from_millis(50)).await;
7805        let stats = handle.stats().await.unwrap();
7806        assert_eq!(stats.state, TorrentState::Paused);
7807
7808        handle.resume().await.unwrap();
7809        tokio::time::sleep(Duration::from_millis(50)).await;
7810        let stats = handle.stats().await.unwrap();
7811        assert_eq!(stats.state, TorrentState::Downloading);
7812
7813        handle.shutdown().await.unwrap();
7814    }
7815
7816    // ---- Test 20: Pause already paused is noop ----
7817
7818    #[tokio::test]
7819    async fn pause_already_paused_is_noop() {
7820        let data = vec![0xEEu8; 32768];
7821        let meta = make_test_torrent(&data, 16384);
7822        let storage = make_storage(&data, 16384);
7823        let config = test_config();
7824        let (atx, amask) = test_alert_channel();
7825        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7826        let handle = TorrentHandle::from_torrent(
7827            meta,
7828            irontide_core::TorrentVersion::V1Only,
7829            None,
7830            dh,
7831            dm,
7832            config,
7833            test_dht_rx(),
7834            test_dht_rx(),
7835            None,
7836            None,
7837            crate::slot_tuner::SlotTuner::disabled(4),
7838            atx,
7839            amask,
7840            None,
7841            None,
7842            test_ban_manager(),
7843            test_ip_filter(),
7844            Arc::new(Vec::new()),
7845            None,
7846            None,
7847            Arc::new(crate::transport::NetworkFactory::tokio()),
7848            None, // M96: hash_pool
7849            Arc::new(crate::stats::SessionCounters::new()),
7850        )
7851        .await
7852        .unwrap();
7853
7854        handle.pause().await.unwrap();
7855        tokio::time::sleep(Duration::from_millis(50)).await;
7856        handle.pause().await.unwrap(); // double pause is fine
7857        tokio::time::sleep(Duration::from_millis(50)).await;
7858        let stats = handle.stats().await.unwrap();
7859        assert_eq!(stats.state, TorrentState::Paused);
7860
7861        handle.shutdown().await.unwrap();
7862    }
7863
7864    // ---- Test 21: Incoming request served from storage ----
7865    //
7866    // Phase 1: Mock seeder feeds piece 0 to the torrent so it becomes verified.
7867    // Phase 2: Mock leecher connects and requests piece 0, verifying upload pipeline.
7868
7869    #[tokio::test]
7870    async fn incoming_request_served_from_storage() {
7871        let data = vec![0xABu8; 16384];
7872        let meta = make_test_torrent(&data, 16384);
7873        let info_hash = meta.info_hash;
7874        let storage = make_storage(&data, 16384);
7875
7876        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7877        let listen_addr = listener.local_addr().unwrap();
7878        drop(listener);
7879
7880        let config = TorrentConfig {
7881            listen_port: listen_addr.port(),
7882            ..test_config()
7883        };
7884
7885        let (atx, amask) = test_alert_channel();
7886        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7887        let handle = TorrentHandle::from_torrent(
7888            meta,
7889            irontide_core::TorrentVersion::V1Only,
7890            None,
7891            dh,
7892            dm,
7893            config,
7894            test_dht_rx(),
7895            test_dht_rx(),
7896            None,
7897            None,
7898            crate::slot_tuner::SlotTuner::disabled(4),
7899            atx,
7900            amask,
7901            None,
7902            None,
7903            test_ban_manager(),
7904            test_ip_filter(),
7905            Arc::new(Vec::new()),
7906            None,
7907            None,
7908            Arc::new(crate::transport::NetworkFactory::tokio()),
7909            None, // M96: hash_pool
7910            Arc::new(crate::stats::SessionCounters::new()),
7911        )
7912        .await
7913        .unwrap();
7914
7915        tokio::time::sleep(Duration::from_millis(50)).await;
7916
7917        // Phase 1: Seed the torrent with piece 0
7918        let seed_data = data.clone();
7919        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7920        let seeder_task = tokio::spawn(async move {
7921            let (reader, writer) = tokio::io::split(seed_stream);
7922            let mut writer = writer;
7923            let mut reader = reader;
7924
7925            let hs = Handshake::new(
7926                info_hash,
7927                Id20::from_hex("6666666666666666666666666666666666666666").unwrap(),
7928            );
7929            writer.write_all(&hs.to_bytes()).await.unwrap();
7930            writer.flush().await.unwrap();
7931            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7932            reader.read_exact(&mut hs_buf).await.unwrap();
7933
7934            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7935            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7936
7937            let _msg = framed_read.next().await; // ext handshake
7938            let ext_hs = ExtHandshake::new();
7939            let payload = ext_hs.to_bytes().unwrap();
7940            framed_write
7941                .send(Message::Extended { ext_id: 0, payload })
7942                .await
7943                .unwrap();
7944
7945            // Send bitfield + unchoke
7946            let mut bf = Bitfield::new(1);
7947            bf.set(0);
7948            framed_write
7949                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7950                .await
7951                .unwrap();
7952            framed_write.send(Message::Unchoke).await.unwrap();
7953
7954            // Respond to requests
7955            while let Some(Ok(msg)) = framed_read.next().await {
7956                if let Message::Request {
7957                    index,
7958                    begin,
7959                    length,
7960                } = msg
7961                {
7962                    let start = begin as usize;
7963                    let end = start + length as usize;
7964                    framed_write
7965                        .send(Message::Piece {
7966                            index,
7967                            begin,
7968                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7969                            data_1: Bytes::new(),
7970                        })
7971                        .await
7972                        .unwrap();
7973                }
7974            }
7975        });
7976
7977        // Wait for download to complete
7978        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7979        loop {
7980            tokio::time::sleep(Duration::from_millis(100)).await;
7981            let stats = handle.stats().await.unwrap();
7982            if stats.pieces_have == 1 {
7983                break;
7984            }
7985            assert!(
7986                tokio::time::Instant::now() <= deadline,
7987                "piece download did not complete within 5s"
7988            );
7989        }
7990
7991        // Phase 2: Connect a mock leecher to request piece 0 back
7992        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7993        let expected_data = data.clone();
7994        let leecher_task = tokio::spawn(async move {
7995            let (reader, writer) = tokio::io::split(leech_stream);
7996            let mut writer = writer;
7997            let mut reader = reader;
7998
7999            let hs = Handshake::new(
8000                info_hash,
8001                Id20::from_hex("7777777777777777777777777777777777777777").unwrap(),
8002            );
8003            writer.write_all(&hs.to_bytes()).await.unwrap();
8004            writer.flush().await.unwrap();
8005            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8006            reader.read_exact(&mut hs_buf).await.unwrap();
8007
8008            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8009            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8010
8011            let _msg = framed_read.next().await; // ext handshake
8012            let ext_hs = ExtHandshake::new();
8013            let payload = ext_hs.to_bytes().unwrap();
8014            framed_write
8015                .send(Message::Extended { ext_id: 0, payload })
8016                .await
8017                .unwrap();
8018
8019            // Send Interested and wait for Unchoke
8020            framed_write.send(Message::Interested).await.unwrap();
8021
8022            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
8023            loop {
8024                tokio::select! {
8025                    msg = framed_read.next() => {
8026                        match msg {
8027                            Some(Ok(Message::Unchoke)) => { break; }
8028                            Some(Ok(_)) => {}
8029                            _ => panic!("connection closed before unchoke"),
8030                        }
8031                    }
8032                    () = tokio::time::sleep_until(deadline) => {
8033                        panic!("timed out waiting for unchoke");
8034                    }
8035                }
8036            }
8037
8038            // Request piece 0
8039            framed_write
8040                .send(Message::Request {
8041                    index: 0,
8042                    begin: 0,
8043                    length: 16384,
8044                })
8045                .await
8046                .unwrap();
8047
8048            // Read Piece response
8049            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
8050            loop {
8051                tokio::select! {
8052                    msg = framed_read.next() => {
8053                        match msg {
8054                            Some(Ok(Message::Piece { index, begin, data_0, data_1 })) => {
8055                                assert_eq!(index, 0);
8056                                assert_eq!(begin, 0);
8057                                let _ = &data_1; // empty after wire round-trip
8058                                assert_eq!(data_0.as_ref(), expected_data.as_slice());
8059                                return; // success
8060                            }
8061                            Some(Ok(_)) => {}
8062                            Some(Err(e)) => panic!("error reading: {e}"),
8063                            None => panic!("connection closed before piece"),
8064                        }
8065                    }
8066                    () = tokio::time::sleep_until(deadline) => {
8067                        panic!("timed out waiting for piece data");
8068                    }
8069                }
8070            }
8071        });
8072
8073        // Wait for leecher to complete
8074        let result = tokio::time::timeout(Duration::from_secs(20), leecher_task).await;
8075        match result {
8076            Ok(Ok(())) => {}
8077            Ok(Err(e)) => panic!("leecher task panicked: {e}"),
8078            Err(elapsed) => panic!("test timed out after {elapsed}"),
8079        }
8080
8081        // Verify uploaded bytes
8082        let stats = handle.stats().await.unwrap();
8083        assert!(
8084            stats.uploaded > 0,
8085            "expected uploaded > 0, got {}",
8086            stats.uploaded
8087        );
8088
8089        handle.shutdown().await.unwrap();
8090        seeder_task.abort();
8091    }
8092
8093    // ---- Test 22: Seed ratio limit stops torrent ----
8094
8095    #[tokio::test]
8096    async fn seed_ratio_limit_stops_torrent() {
8097        // 1-piece torrent, ratio limit = 1.0
8098        // After downloading 16384 bytes and uploading 16384 bytes, ratio = 1.0 → stop
8099        let data = vec![0xCCu8; 16384];
8100        let meta = make_test_torrent(&data, 16384);
8101        let info_hash = meta.info_hash;
8102        let storage = make_storage(&data, 16384);
8103
8104        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8105        let listen_addr = listener.local_addr().unwrap();
8106        drop(listener);
8107
8108        let config = TorrentConfig {
8109            listen_port: listen_addr.port(),
8110            seed_ratio_limit: Some(1.0),
8111            ..test_config()
8112        };
8113
8114        let (atx, amask) = test_alert_channel();
8115        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8116        let handle = TorrentHandle::from_torrent(
8117            meta,
8118            irontide_core::TorrentVersion::V1Only,
8119            None,
8120            dh,
8121            dm,
8122            config,
8123            test_dht_rx(),
8124            test_dht_rx(),
8125            None,
8126            None,
8127            crate::slot_tuner::SlotTuner::disabled(4),
8128            atx,
8129            amask,
8130            None,
8131            None,
8132            test_ban_manager(),
8133            test_ip_filter(),
8134            Arc::new(Vec::new()),
8135            None,
8136            None,
8137            Arc::new(crate::transport::NetworkFactory::tokio()),
8138            None, // M96: hash_pool
8139            Arc::new(crate::stats::SessionCounters::new()),
8140        )
8141        .await
8142        .unwrap();
8143
8144        tokio::time::sleep(Duration::from_millis(50)).await;
8145
8146        // Phase 1: Seed the torrent with piece 0
8147        let seed_data = data.clone();
8148        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8149        let seeder_task = tokio::spawn(async move {
8150            let (reader, writer) = tokio::io::split(seed_stream);
8151            let mut writer = writer;
8152            let mut reader = reader;
8153
8154            let hs = Handshake::new(
8155                info_hash,
8156                Id20::from_hex("8888888888888888888888888888888888888888").unwrap(),
8157            );
8158            writer.write_all(&hs.to_bytes()).await.unwrap();
8159            writer.flush().await.unwrap();
8160            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8161            reader.read_exact(&mut hs_buf).await.unwrap();
8162
8163            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8164            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8165
8166            let _msg = framed_read.next().await;
8167            let ext_hs = ExtHandshake::new();
8168            let payload = ext_hs.to_bytes().unwrap();
8169            framed_write
8170                .send(Message::Extended { ext_id: 0, payload })
8171                .await
8172                .unwrap();
8173
8174            let mut bf = Bitfield::new(1);
8175            bf.set(0);
8176            framed_write
8177                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
8178                .await
8179                .unwrap();
8180            framed_write.send(Message::Unchoke).await.unwrap();
8181
8182            while let Some(Ok(msg)) = framed_read.next().await {
8183                if let Message::Request {
8184                    index,
8185                    begin,
8186                    length,
8187                } = msg
8188                {
8189                    let start = begin as usize;
8190                    let end = start + length as usize;
8191                    framed_write
8192                        .send(Message::Piece {
8193                            index,
8194                            begin,
8195                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
8196                            data_1: Bytes::new(),
8197                        })
8198                        .await
8199                        .unwrap();
8200                }
8201            }
8202        });
8203
8204        // Wait for download to complete (transitions to Seeding)
8205        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
8206        loop {
8207            tokio::time::sleep(Duration::from_millis(100)).await;
8208            let stats = handle.stats().await.unwrap();
8209            if stats.state == TorrentState::Seeding {
8210                break;
8211            }
8212            assert!(
8213                tokio::time::Instant::now() <= deadline,
8214                "download did not complete within 5s"
8215            );
8216        }
8217
8218        // Phase 2: Connect leecher to request piece 0 — this should trigger ratio limit
8219        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8220        let leecher_task = tokio::spawn(async move {
8221            let (reader, writer) = tokio::io::split(leech_stream);
8222            let mut writer = writer;
8223            let mut reader = reader;
8224
8225            let hs = Handshake::new(
8226                info_hash,
8227                Id20::from_hex("9999999999999999999999999999999999999999").unwrap(),
8228            );
8229            writer.write_all(&hs.to_bytes()).await.unwrap();
8230            writer.flush().await.unwrap();
8231            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8232            reader.read_exact(&mut hs_buf).await.unwrap();
8233
8234            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8235            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8236
8237            let _msg = framed_read.next().await;
8238            let ext_hs = ExtHandshake::new();
8239            let payload = ext_hs.to_bytes().unwrap();
8240            framed_write
8241                .send(Message::Extended { ext_id: 0, payload })
8242                .await
8243                .unwrap();
8244
8245            framed_write.send(Message::Interested).await.unwrap();
8246
8247            // Wait for unchoke
8248            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
8249            loop {
8250                tokio::select! {
8251                    msg = framed_read.next() => {
8252                        match msg {
8253                            Some(Ok(Message::Unchoke)) => break,
8254                            Some(Ok(_)) => {}
8255                            _ => return, // connection may close due to ratio shutdown
8256                        }
8257                    }
8258                    () = tokio::time::sleep_until(deadline) => return,
8259                }
8260            }
8261
8262            // Request piece 0
8263            framed_write
8264                .send(Message::Request {
8265                    index: 0,
8266                    begin: 0,
8267                    length: 16384,
8268                })
8269                .await
8270                .unwrap();
8271
8272            // Read until connection closes (the torrent may stop and disconnect us)
8273            while let Some(Ok(_msg)) = framed_read.next().await {}
8274        });
8275
8276        // Wait for state to become Stopped
8277        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
8278        loop {
8279            tokio::time::sleep(Duration::from_millis(100)).await;
8280            let stats = handle.stats().await.unwrap();
8281            if stats.state == TorrentState::Stopped {
8282                assert!(
8283                    stats.uploaded >= 16384,
8284                    "expected uploaded >= 16384, got {}",
8285                    stats.uploaded
8286                );
8287                break;
8288            }
8289            if tokio::time::Instant::now() > deadline {
8290                let stats = handle.stats().await.unwrap();
8291                panic!(
8292                    "expected Stopped, got {:?}, uploaded={}, downloaded={}",
8293                    stats.state, stats.uploaded, stats.downloaded
8294                );
8295            }
8296        }
8297
8298        handle.shutdown().await.unwrap();
8299        seeder_task.abort();
8300        leecher_task.abort();
8301    }
8302
8303    // ---- Test 23: Resume with seeded storage starts as seeder ----
8304
8305    #[tokio::test]
8306    async fn resume_with_seeded_storage() {
8307        let data = vec![0xDDu8; 32768]; // 2 pieces
8308        let meta = make_test_torrent(&data, 16384);
8309        let storage = make_seeded_storage(&data, 16384);
8310        let config = test_config();
8311
8312        let (atx, amask) = test_alert_channel();
8313        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8314        let handle = TorrentHandle::from_torrent(
8315            meta,
8316            irontide_core::TorrentVersion::V1Only,
8317            None,
8318            dh,
8319            dm,
8320            config,
8321            test_dht_rx(),
8322            test_dht_rx(),
8323            None,
8324            None,
8325            crate::slot_tuner::SlotTuner::disabled(4),
8326            atx,
8327            amask,
8328            None,
8329            None,
8330            test_ban_manager(),
8331            test_ip_filter(),
8332            Arc::new(Vec::new()),
8333            None,
8334            None,
8335            Arc::new(crate::transport::NetworkFactory::tokio()),
8336            None, // M96: hash_pool
8337            Arc::new(crate::stats::SessionCounters::new()),
8338        )
8339        .await
8340        .unwrap();
8341
8342        // Give the actor time to verify existing pieces
8343        tokio::time::sleep(Duration::from_millis(100)).await;
8344
8345        let stats = handle.stats().await.unwrap();
8346        assert_eq!(
8347            stats.state,
8348            TorrentState::Seeding,
8349            "should start as seeder with all pieces verified"
8350        );
8351        assert_eq!(stats.pieces_have, 2);
8352        assert_eq!(stats.pieces_total, 2);
8353
8354        handle.shutdown().await.unwrap();
8355    }
8356
8357    // ---- Test: save_resume_data captures state ----
8358
8359    #[tokio::test]
8360    async fn save_resume_data_captures_state() {
8361        let data = vec![0xAB; 32768];
8362        let meta = make_test_torrent(&data, 16384);
8363        let info_hash = meta.info_hash;
8364        let storage = make_storage(&data, 16384);
8365        let config = test_config();
8366
8367        let (atx, amask) = test_alert_channel();
8368        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8369        let handle = TorrentHandle::from_torrent(
8370            meta,
8371            irontide_core::TorrentVersion::V1Only,
8372            None,
8373            dh,
8374            dm,
8375            config,
8376            test_dht_rx(),
8377            test_dht_rx(),
8378            None,
8379            None,
8380            crate::slot_tuner::SlotTuner::disabled(4),
8381            atx,
8382            amask,
8383            None,
8384            None,
8385            test_ban_manager(),
8386            test_ip_filter(),
8387            Arc::new(Vec::new()),
8388            None,
8389            None,
8390            Arc::new(crate::transport::NetworkFactory::tokio()),
8391            None, // M96: hash_pool
8392            Arc::new(crate::stats::SessionCounters::new()),
8393        )
8394        .await
8395        .unwrap();
8396
8397        // Give actor time to start
8398        tokio::time::sleep(Duration::from_millis(50)).await;
8399
8400        let rd = handle.save_resume_data().await.unwrap();
8401
8402        assert_eq!(rd.file_format, "libtorrent resume file");
8403        assert_eq!(rd.file_version, 1);
8404        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8405        assert_eq!(rd.name, "test");
8406        assert_eq!(rd.save_path, "/tmp");
8407        assert_eq!(rd.paused, 0);
8408        // No pieces downloaded yet — bitfield should be all zeros
8409        assert!(!rd.pieces.is_empty());
8410        // Stats should be zero for a freshly started torrent with no peers
8411        assert_eq!(rd.total_uploaded, 0);
8412        assert_eq!(rd.total_downloaded, 0);
8413
8414        handle.shutdown().await.unwrap();
8415    }
8416
8417    // ---- Test: save_resume_data for seeder ----
8418
8419    #[tokio::test]
8420    async fn save_resume_data_seeder() {
8421        let data = vec![0xCD; 32768];
8422        let meta = make_test_torrent(&data, 16384);
8423        let info_hash = meta.info_hash;
8424        let storage = make_seeded_storage(&data, 16384);
8425        let config = test_config();
8426
8427        let (atx, amask) = test_alert_channel();
8428        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8429        let handle = TorrentHandle::from_torrent(
8430            meta,
8431            irontide_core::TorrentVersion::V1Only,
8432            None,
8433            dh,
8434            dm,
8435            config,
8436            test_dht_rx(),
8437            test_dht_rx(),
8438            None,
8439            None,
8440            crate::slot_tuner::SlotTuner::disabled(4),
8441            atx,
8442            amask,
8443            None,
8444            None,
8445            test_ban_manager(),
8446            test_ip_filter(),
8447            Arc::new(Vec::new()),
8448            None,
8449            None,
8450            Arc::new(crate::transport::NetworkFactory::tokio()),
8451            None, // M96: hash_pool
8452            Arc::new(crate::stats::SessionCounters::new()),
8453        )
8454        .await
8455        .unwrap();
8456
8457        // Give actor time to verify pieces and switch to seeding
8458        tokio::time::sleep(Duration::from_millis(100)).await;
8459
8460        let rd = handle.save_resume_data().await.unwrap();
8461
8462        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8463        assert_eq!(rd.name, "test");
8464        assert_eq!(rd.seed_mode, 1, "seeder should have seed_mode=1");
8465        assert_eq!(rd.paused, 0);
8466        // All pieces should be marked in the bitfield
8467        // 2 pieces -> 1 byte, top 2 bits set = 0b1100_0000 = 0xC0
8468        assert_eq!(rd.pieces.len(), 1);
8469        assert_eq!(
8470            rd.pieces[0] & 0xC0,
8471            0xC0,
8472            "both pieces should be marked complete"
8473        );
8474
8475        handle.shutdown().await.unwrap();
8476    }
8477
8478    // ---- Test: save_resume_data for paused torrent ----
8479
8480    #[tokio::test]
8481    async fn save_resume_data_paused() {
8482        let data = vec![0xEF; 16384];
8483        let meta = make_test_torrent(&data, 16384);
8484        let storage = make_storage(&data, 16384);
8485        let config = test_config();
8486
8487        let (atx, amask) = test_alert_channel();
8488        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8489        let handle = TorrentHandle::from_torrent(
8490            meta,
8491            irontide_core::TorrentVersion::V1Only,
8492            None,
8493            dh,
8494            dm,
8495            config,
8496            test_dht_rx(),
8497            test_dht_rx(),
8498            None,
8499            None,
8500            crate::slot_tuner::SlotTuner::disabled(4),
8501            atx,
8502            amask,
8503            None,
8504            None,
8505            test_ban_manager(),
8506            test_ip_filter(),
8507            Arc::new(Vec::new()),
8508            None,
8509            None,
8510            Arc::new(crate::transport::NetworkFactory::tokio()),
8511            None, // M96: hash_pool
8512            Arc::new(crate::stats::SessionCounters::new()),
8513        )
8514        .await
8515        .unwrap();
8516
8517        tokio::time::sleep(Duration::from_millis(50)).await;
8518        handle.pause().await.unwrap();
8519        tokio::time::sleep(Duration::from_millis(50)).await;
8520
8521        let rd = handle.save_resume_data().await.unwrap();
8522        assert_eq!(rd.paused, 1, "paused torrent should have paused=1");
8523        assert_eq!(rd.seed_mode, 0);
8524
8525        handle.shutdown().await.unwrap();
8526    }
8527
8528    // ---- Test: set_file_priority and read back ----
8529
8530    #[tokio::test]
8531    async fn set_file_priority_and_read_back() {
8532        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8533        let mut torrent_bytes = b"d4:info".to_vec();
8534        torrent_bytes.extend_from_slice(info_bytes);
8535        torrent_bytes.push(b'e');
8536
8537        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8538        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8539        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8540        let config = TorrentConfig {
8541            listen_port: 0,
8542            ..Default::default()
8543        };
8544
8545        let (atx, amask) = test_alert_channel();
8546        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8547        let handle = TorrentHandle::from_torrent(
8548            meta,
8549            irontide_core::TorrentVersion::V1Only,
8550            None,
8551            dh,
8552            dm,
8553            config,
8554            test_dht_rx(),
8555            test_dht_rx(),
8556            None,
8557            None,
8558            crate::slot_tuner::SlotTuner::disabled(4),
8559            atx,
8560            amask,
8561            None,
8562            None,
8563            test_ban_manager(),
8564            test_ip_filter(),
8565            Arc::new(Vec::new()),
8566            None,
8567            None,
8568            Arc::new(crate::transport::NetworkFactory::tokio()),
8569            None, // M96: hash_pool
8570            Arc::new(crate::stats::SessionCounters::new()),
8571        )
8572        .await
8573        .unwrap();
8574
8575        // Default priorities should all be Normal
8576        let prios = handle.file_priorities().await.unwrap();
8577        assert_eq!(prios.len(), 2);
8578        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8579
8580        // Set file 0 to Skip
8581        handle
8582            .set_file_priority(0, FilePriority::Skip)
8583            .await
8584            .unwrap();
8585
8586        let prios = handle.file_priorities().await.unwrap();
8587        assert_eq!(prios[0], FilePriority::Skip);
8588        assert_eq!(prios[1], FilePriority::Normal);
8589
8590        // Invalid index should error
8591        let result = handle.set_file_priority(99, FilePriority::High).await;
8592        assert!(result.is_err());
8593
8594        handle.shutdown().await.unwrap();
8595        tokio::time::sleep(Duration::from_millis(50)).await;
8596    }
8597
8598    /// Spawn a running multi-file torrent (3 files, 4 pieces, one shared
8599    /// boundary piece: file b at 150 B spans pieces 1-2, file c spans 2-3) for
8600    /// end-to-end `SetFilePriority` arm coverage.
8601    async fn spawn_test_torrent_multifile() -> TorrentHandle {
8602        let meta = make_multi_file_meta(&[(100, "a.bin"), (150, "b.bin"), (100, "c.bin")], 100);
8603        let lengths = Lengths::new(350, 100, DEFAULT_CHUNK_SIZE);
8604        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8605        let config = TorrentConfig {
8606            listen_port: 0,
8607            ..Default::default()
8608        };
8609        let (atx, amask) = test_alert_channel();
8610        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8611        TorrentHandle::from_torrent(
8612            meta,
8613            irontide_core::TorrentVersion::V1Only,
8614            None,
8615            dh,
8616            dm,
8617            config,
8618            test_dht_rx(),
8619            test_dht_rx(),
8620            None,
8621            None,
8622            crate::slot_tuner::SlotTuner::disabled(4),
8623            atx,
8624            amask,
8625            None,
8626            None,
8627            test_ban_manager(),
8628            test_ip_filter(),
8629            Arc::new(Vec::new()),
8630            None,
8631            None,
8632            Arc::new(crate::transport::NetworkFactory::tokio()),
8633            None,
8634            Arc::new(crate::stats::SessionCounters::new()),
8635        )
8636        .await
8637        .unwrap()
8638    }
8639
8640    /// M246 characterization: the observable post-conditions of a file-priority
8641    /// change — `file_priorities()` round-trip and the invalid-index error — are
8642    /// synchronous and MUST survive the recv-loop hardening. Only the order-map
8643    /// *build* moves to the 1 s tick; the priority/wanted/atomic state that gates
8644    /// dispatch stays synchronous, so this surface is unchanged before and after.
8645    #[tokio::test]
8646    async fn set_file_priority_updates_wanted_and_priorities() {
8647        let handle = spawn_test_torrent_multifile().await;
8648
8649        let prios = handle.file_priorities().await.unwrap();
8650        assert_eq!(prios.len(), 3);
8651        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8652
8653        handle
8654            .set_file_priority(1, FilePriority::Skip)
8655            .await
8656            .unwrap();
8657        assert_eq!(
8658            handle.file_priorities().await.unwrap()[1],
8659            FilePriority::Skip
8660        );
8661
8662        handle
8663            .set_file_priority(1, FilePriority::Normal)
8664            .await
8665            .unwrap();
8666        assert_eq!(
8667            handle.file_priorities().await.unwrap()[1],
8668            FilePriority::Normal
8669        );
8670
8671        // Invalid index still errors synchronously.
8672        assert!(
8673            handle
8674                .set_file_priority(99, FilePriority::High)
8675                .await
8676                .is_err()
8677        );
8678
8679        handle.shutdown().await.unwrap();
8680        tokio::time::sleep(Duration::from_millis(50)).await;
8681    }
8682
8683    /// Build a synchronous (non-spawned) multi-file `TorrentActor` for direct
8684    /// method-level testing of the M246 range-scoped helpers. Populates
8685    /// `meta`, `lengths`, `cached_files`, `file_priorities` (all `Normal`),
8686    /// `atomic_states`, and `piece_tracker` sized to the layout, on top of
8687    /// `for_throttle_test`'s channel scaffolding. Must run inside a tokio runtime.
8688    fn priority_test_actor(files: &[(u64, &str)], piece_length: u64) -> TorrentActor {
8689        use irontide_storage::Bitfield;
8690        let meta = make_multi_file_meta(files, piece_length);
8691        let total: u64 = files.iter().map(|(l, _)| *l).sum();
8692        let lengths = Lengths::new(total, piece_length, DEFAULT_CHUNK_SIZE);
8693        let num_pieces = lengths.num_pieces();
8694        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8695
8696        let mut actor = TorrentActor::for_throttle_test(num_pieces, 0);
8697        actor.file_priorities = vec![FilePriority::Normal; files.len()];
8698        actor.wanted_pieces = crate::piece_selector::build_wanted_pieces(
8699            &actor.file_priorities,
8700            &file_lengths,
8701            &lengths,
8702        );
8703        actor.cached_files = Some(build_cached_file_info(&meta, &lengths));
8704
8705        let we_have = Bitfield::new(num_pieces);
8706        actor.atomic_states = Some(Arc::new(crate::piece_reservation::AtomicPieceStates::new(
8707            num_pieces,
8708            &we_have,
8709            &actor.wanted_pieces,
8710        )));
8711        actor.piece_tracker = Some(crate::piece_reservation::PieceTracker::new(
8712            num_pieces,
8713            &we_have,
8714            &actor.wanted_pieces,
8715        ));
8716        actor.meta = Some(meta);
8717        actor.lengths = Some(lengths);
8718        actor
8719    }
8720
8721    /// M246 D4: the scoped recompute must leave `wanted_pieces` byte-identical
8722    /// to a full `build_wanted_pieces` for every file, across a misaligned
8723    /// layout with multiple shared boundary pieces (b/c share piece 2; c/d
8724    /// share piece 3).
8725    #[tokio::test]
8726    async fn apply_file_priority_scoped_matches_full_rebuild() {
8727        let files: &[(u64, &str)] = &[(100, "a"), (150, "b"), (100, "c"), (250, "d")];
8728        let piece_length = 100;
8729        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8730        for skip_idx in 0..files.len() {
8731            let mut actor = priority_test_actor(files, piece_length);
8732            actor
8733                .apply_file_priority_scoped(skip_idx, FilePriority::Skip)
8734                .unwrap();
8735
8736            let mut ref_prios = vec![FilePriority::Normal; files.len()];
8737            ref_prios[skip_idx] = FilePriority::Skip;
8738            let reference = crate::piece_selector::build_wanted_pieces(
8739                &ref_prios,
8740                &file_lengths,
8741                actor.lengths.as_ref().unwrap(),
8742            );
8743            for p in 0..actor.num_pieces {
8744                assert_eq!(
8745                    actor.wanted_pieces.get(p),
8746                    reference.get(p),
8747                    "piece {p} mismatch after scoped skip of file {skip_idx}"
8748                );
8749            }
8750        }
8751    }
8752
8753    /// M246 D4: sub-piece-sized files (a,b,c all inside piece 0). The shared
8754    /// piece stays wanted while ANY overlapping file is non-skip, and unwants
8755    /// only when all overlapping files are skipped.
8756    #[tokio::test]
8757    async fn apply_file_priority_scoped_handles_sub_piece_files() {
8758        let files: &[(u64, &str)] = &[(30, "a"), (30, "b"), (40, "c"), (100, "d")];
8759        let mut actor = priority_test_actor(files, 100);
8760
8761        actor
8762            .apply_file_priority_scoped(0, FilePriority::Skip)
8763            .unwrap();
8764        assert!(
8765            actor.wanted_pieces.get(0),
8766            "piece 0 wanted: b,c still want it"
8767        );
8768        actor
8769            .apply_file_priority_scoped(1, FilePriority::Skip)
8770            .unwrap();
8771        actor
8772            .apply_file_priority_scoped(2, FilePriority::Skip)
8773            .unwrap();
8774        assert!(
8775            !actor.wanted_pieces.get(0),
8776            "piece 0 unwanted: a,b,c all skip"
8777        );
8778        assert!(
8779            actor.wanted_pieces.get(1),
8780            "piece 1 still wanted (d Normal)"
8781        );
8782
8783        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8784        let prios = vec![
8785            FilePriority::Skip,
8786            FilePriority::Skip,
8787            FilePriority::Skip,
8788            FilePriority::Normal,
8789        ];
8790        let reference = crate::piece_selector::build_wanted_pieces(
8791            &prios,
8792            &file_lengths,
8793            actor.lengths.as_ref().unwrap(),
8794        );
8795        for p in 0..actor.num_pieces {
8796            assert_eq!(
8797                actor.wanted_pieces.get(p),
8798                reference.get(p),
8799                "piece {p} mismatch"
8800            );
8801        }
8802    }
8803
8804    /// M246 D4: a zero-length file (middle of the layout) yields an empty range
8805    /// and must not panic; the result still matches a full rebuild.
8806    #[tokio::test]
8807    async fn apply_file_priority_scoped_zero_length_file_no_panic() {
8808        let files: &[(u64, &str)] = &[(100, "a"), (0, "empty"), (100, "c")];
8809        let mut actor = priority_test_actor(files, 100);
8810
8811        let r = actor
8812            .apply_file_priority_scoped(1, FilePriority::Skip)
8813            .unwrap();
8814        assert!(
8815            r.0 > r.1,
8816            "zero-length file yields an empty range, got {r:?}"
8817        );
8818
8819        actor
8820            .apply_file_priority_scoped(0, FilePriority::Skip)
8821            .unwrap();
8822
8823        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8824        let prios = vec![FilePriority::Skip, FilePriority::Skip, FilePriority::Normal];
8825        let reference = crate::piece_selector::build_wanted_pieces(
8826            &prios,
8827            &file_lengths,
8828            actor.lengths.as_ref().unwrap(),
8829        );
8830        for p in 0..actor.num_pieces {
8831            assert_eq!(
8832                actor.wanted_pieces.get(p),
8833                reference.get(p),
8834                "piece {p} mismatch"
8835            );
8836        }
8837    }
8838
8839    /// M246: `sync_piece_states_for_range` touches ONLY pieces inside the range;
8840    /// pieces outside stay at their prior atomic state.
8841    #[tokio::test]
8842    async fn sync_piece_states_for_range_only_touches_range() {
8843        let files: &[(u64, &str)] = &[(200, "a"), (200, "b"), (200, "c")];
8844        let mut actor = priority_test_actor(files, 100); // 6 pieces; file 1 = pieces 2,3
8845        let (first, last) = actor
8846            .apply_file_priority_scoped(1, FilePriority::Skip)
8847            .unwrap();
8848        assert_eq!((first, last), (2, 3));
8849        actor.sync_piece_states_for_range(first, last);
8850
8851        let atomic = actor.atomic_states.as_ref().unwrap();
8852        assert_eq!(
8853            atomic.get(2),
8854            crate::piece_reservation::PieceState::Unwanted
8855        );
8856        assert_eq!(
8857            atomic.get(3),
8858            crate::piece_reservation::PieceState::Unwanted
8859        );
8860        for p in [0u32, 1, 4, 5] {
8861            assert_eq!(
8862                atomic.get(p),
8863                crate::piece_reservation::PieceState::Available,
8864                "piece {p} outside the range must be untouched"
8865            );
8866        }
8867    }
8868
8869    /// M246 FINDING-1 regression: the order-map rebuild is DEFERRED (no rebuild
8870    /// before the tick), a batch coalesces to ONE rebuild, the generation comes
8871    /// from the actor-owned monotone counter (advances by exactly 1), and the
8872    /// SECOND of two back-to-back batch changes is NOT lost — the failure mode
8873    /// of the rejected Candidate H (read published gen, async publish → drop).
8874    #[tokio::test]
8875    async fn order_map_coalesces_and_gen_is_monotone() {
8876        // 3 files × 200 B, piece_length 100 → f0=[0,1], f1=[2,3], f2=[4,5].
8877        let mut actor = priority_test_actor(&[(200, "a"), (200, "b"), (200, "c")], 100);
8878        let gen0 = actor.order_map_tx.borrow().generation;
8879
8880        // Two back-to-back scoped priority changes (the batch case). The arm sets
8881        // the dirty flag; we drive the helper + flag directly here.
8882        actor
8883            .apply_file_priority_scoped(0, FilePriority::Skip)
8884            .unwrap();
8885        actor.order_map_dirty = true;
8886        actor
8887            .apply_file_priority_scoped(2, FilePriority::Skip)
8888            .unwrap();
8889        actor.order_map_dirty = true;
8890        assert_eq!(
8891            actor.order_map_tx.borrow().generation,
8892            gen0,
8893            "no order-map rebuild before the tick"
8894        );
8895
8896        // The tick rebuilds ONCE, capturing BOTH changes; gen advances by 1.
8897        actor.rebuild_order_map_now();
8898        assert_eq!(
8899            actor.order_map_tx.borrow().generation,
8900            gen0 + 1,
8901            "exactly one coalesced rebuild"
8902        );
8903        assert!(!actor.order_map_dirty, "dirty flag cleared after rebuild");
8904
8905        // Both skipped files' pieces are absent; file 1's remain present — proof
8906        // the second change was not dropped.
8907        let map = actor.order_map_tx.borrow();
8908        for p in [0u32, 1, 4, 5] {
8909            assert!(
8910                !map.order.contains(&p),
8911                "skipped piece {p} must be absent from the order"
8912            );
8913        }
8914        for p in [2u32, 3] {
8915            assert!(
8916                map.order.contains(&p),
8917                "wanted piece {p} must be present in the order"
8918            );
8919        }
8920    }
8921
8922    /// M246 Q6: the post-recheck reconfigure path delegates to
8923    /// `rebuild_order_map_now`, which clears a pending dirty flag so the next
8924    /// tick does not redundantly rebuild.
8925    #[tokio::test]
8926    async fn rebuild_order_map_now_clears_dirty_flag() {
8927        let mut actor = priority_test_actor(&[(200, "a"), (200, "b")], 100);
8928        actor.order_map_dirty = true;
8929        actor.rebuild_order_map_now();
8930        assert!(!actor.order_map_dirty);
8931    }
8932
8933    #[tokio::test]
8934    async fn resume_data_preserves_file_priorities() {
8935        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8936        let mut torrent_bytes = b"d4:info".to_vec();
8937        torrent_bytes.extend_from_slice(info_bytes);
8938        torrent_bytes.push(b'e');
8939
8940        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8941        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8942        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8943        let config = TorrentConfig {
8944            listen_port: 0,
8945            ..Default::default()
8946        };
8947
8948        let (atx, amask) = test_alert_channel();
8949        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8950        let handle = TorrentHandle::from_torrent(
8951            meta,
8952            irontide_core::TorrentVersion::V1Only,
8953            None,
8954            dh,
8955            dm,
8956            config,
8957            test_dht_rx(),
8958            test_dht_rx(),
8959            None,
8960            None,
8961            crate::slot_tuner::SlotTuner::disabled(4),
8962            atx,
8963            amask,
8964            None,
8965            None,
8966            test_ban_manager(),
8967            test_ip_filter(),
8968            Arc::new(Vec::new()),
8969            None,
8970            None,
8971            Arc::new(crate::transport::NetworkFactory::tokio()),
8972            None, // M96: hash_pool
8973            Arc::new(crate::stats::SessionCounters::new()),
8974        )
8975        .await
8976        .unwrap();
8977
8978        // Set file priorities
8979        handle
8980            .set_file_priority(0, FilePriority::High)
8981            .await
8982            .unwrap();
8983        handle
8984            .set_file_priority(1, FilePriority::Skip)
8985            .await
8986            .unwrap();
8987
8988        // Save resume data
8989        let rd = handle.save_resume_data().await.unwrap();
8990        assert_eq!(rd.file_priority, vec![7, 0]); // High=7, Skip=0
8991
8992        // Verify bencode round-trip
8993        let encoded = irontide_bencode::to_bytes(&rd).unwrap();
8994        let decoded: irontide_core::FastResumeData =
8995            irontide_bencode::from_bytes(&encoded).unwrap();
8996        assert_eq!(decoded.file_priority, vec![7, 0]);
8997
8998        handle.shutdown().await.unwrap();
8999        tokio::time::sleep(Duration::from_millis(50)).await;
9000    }
9001
9002    // ---- Rate limiting integration tests (M14) ----
9003
9004    #[tokio::test]
9005    async fn upload_rate_limiting_caps_throughput() {
9006        // Test that per-torrent upload rate limiting gates serve_incoming_requests.
9007        // We use a very low rate (1 KB/s) so the 16 KB piece requires ~16 seconds.
9008        // We verify: 1) piece does NOT arrive within 200ms (bucket too small),
9009        //            2) the torrent actor is alive and functional.
9010        let data = vec![0xAB; 16384]; // 1 piece
9011        let meta = make_test_torrent(&data, 16384);
9012        let info_hash = meta.info_hash;
9013        let storage = make_seeded_storage(&data, 16384);
9014
9015        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9016        let listen_addr = listener.local_addr().unwrap();
9017
9018        let config = TorrentConfig {
9019            listen_port: listen_addr.port(),
9020            upload_rate_limit: 1024, // 1 KB/s — way too slow for 16 KB chunk
9021            ..test_config()
9022        };
9023
9024        drop(listener);
9025        let (atx, amask) = test_alert_channel();
9026        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9027        let handle = TorrentHandle::from_torrent(
9028            meta,
9029            irontide_core::TorrentVersion::V1Only,
9030            None,
9031            dh,
9032            dm,
9033            config,
9034            test_dht_rx(),
9035            test_dht_rx(),
9036            None,
9037            None,
9038            crate::slot_tuner::SlotTuner::disabled(4),
9039            atx,
9040            amask,
9041            None,
9042            None,
9043            test_ban_manager(),
9044            test_ip_filter(),
9045            Arc::new(Vec::new()),
9046            None,
9047            None,
9048            Arc::new(crate::transport::NetworkFactory::tokio()),
9049            None, // M96: hash_pool
9050            Arc::new(crate::stats::SessionCounters::new()),
9051        )
9052        .await
9053        .unwrap();
9054
9055        tokio::time::sleep(Duration::from_millis(50)).await;
9056
9057        // Connect mock leecher (raw handshake + framed messages)
9058        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
9059        let (reader, writer) = tokio::io::split(stream);
9060        let mut writer = writer;
9061        let mut reader = reader;
9062
9063        let hs = Handshake::new(
9064            info_hash,
9065            Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
9066        );
9067        writer.write_all(&hs.to_bytes()).await.unwrap();
9068        writer.flush().await.unwrap();
9069        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
9070        reader.read_exact(&mut hs_buf).await.unwrap();
9071
9072        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
9073        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
9074
9075        // Read ext handshake + bitfield
9076        let _msg = framed_read.next().await;
9077        let ext_hs = ExtHandshake::new();
9078        let payload = ext_hs.to_bytes().unwrap();
9079        framed_write
9080            .send(Message::Extended { ext_id: 0, payload })
9081            .await
9082            .unwrap();
9083
9084        // Read the bitfield
9085        let _bf_msg = framed_read.next().await;
9086
9087        // Express interest
9088        framed_write.send(Message::Interested).await.unwrap();
9089
9090        // Wait for unchoke
9091        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
9092        loop {
9093            tokio::select! {
9094                msg = framed_read.next() => {
9095                    match msg {
9096                        Some(Ok(Message::Unchoke)) => break,
9097                        Some(Ok(_)) => {}
9098                        _ => panic!("connection closed before unchoke"),
9099                    }
9100                }
9101                () = tokio::time::sleep_until(deadline) => {
9102                    panic!("timed out waiting for unchoke");
9103                }
9104            }
9105        }
9106
9107        // Request piece 0
9108        framed_write
9109            .send(Message::Request {
9110                index: 0,
9111                begin: 0,
9112                length: 16384,
9113            })
9114            .await
9115            .unwrap();
9116
9117        // At 1 KB/s, the bucket accumulates ~100 bytes per 100ms tick (max burst = 1024).
9118        // A 16 KB chunk needs 16384 tokens, so it should NOT be served quickly.
9119        // We wait 2 seconds — at 1 KB/s we'd have at most 2 KB, still < 16 KB.
9120        let mut got_piece = false;
9121        if let Ok(true) = tokio::time::timeout(Duration::from_secs(2), async {
9122            loop {
9123                match framed_read.next().await {
9124                    Some(Ok(Message::Piece { .. })) => return true,
9125                    Some(Ok(_)) => {}
9126                    _ => return false,
9127                }
9128            }
9129        })
9130        .await
9131        {
9132            got_piece = true;
9133        }
9134
9135        // Piece should NOT have arrived in 2 seconds (would need 16s at 1 KB/s)
9136        assert!(
9137            !got_piece,
9138            "piece should be delayed by rate limiter (1 KB/s for 16 KB chunk)"
9139        );
9140
9141        // Verify actor is still alive
9142        let stats = handle.stats().await.unwrap();
9143        assert_eq!(stats.uploaded, 0); // nothing served yet
9144
9145        handle.shutdown().await.unwrap();
9146    }
9147
9148    #[tokio::test]
9149    async fn unlimited_rate_has_no_effect() {
9150        // Default config (rate = 0) should behave identically to pre-M14
9151        let data = vec![0xAB; 32768];
9152        let meta = make_test_torrent(&data, 16384);
9153        let storage = make_storage(&data, 16384);
9154        let config = test_config();
9155
9156        // Rate limits are 0 (unlimited) by default
9157        assert_eq!(config.upload_rate_limit, 0);
9158        assert_eq!(config.download_rate_limit, 0);
9159
9160        let (atx, amask) = test_alert_channel();
9161        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9162        let handle = TorrentHandle::from_torrent(
9163            meta,
9164            irontide_core::TorrentVersion::V1Only,
9165            None,
9166            dh,
9167            dm,
9168            config,
9169            test_dht_rx(),
9170            test_dht_rx(),
9171            None,
9172            None,
9173            crate::slot_tuner::SlotTuner::disabled(4),
9174            atx,
9175            amask,
9176            None,
9177            None,
9178            test_ban_manager(),
9179            test_ip_filter(),
9180            Arc::new(Vec::new()),
9181            None,
9182            None,
9183            Arc::new(crate::transport::NetworkFactory::tokio()),
9184            None, // M96: hash_pool
9185            Arc::new(crate::stats::SessionCounters::new()),
9186        )
9187        .await
9188        .unwrap();
9189
9190        let stats = handle.stats().await.unwrap();
9191        assert_eq!(stats.state, TorrentState::Downloading);
9192        assert_eq!(stats.pieces_total, 2);
9193
9194        handle.shutdown().await.unwrap();
9195    }
9196
9197    #[tokio::test]
9198    async fn download_rate_limiting_throttles_requests() {
9199        // Test that download_rate_limit prevents sending requests when budget exhausted.
9200        // With 1 KB/s limit and 16 KB chunks, budget is exhausted almost immediately.
9201        let data = vec![0xAB; 32768];
9202        let meta = make_test_torrent(&data, 16384);
9203        let info_hash = meta.info_hash;
9204        let storage = make_storage(&data, 16384);
9205
9206        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9207        let listen_addr = listener.local_addr().unwrap();
9208
9209        let config = TorrentConfig {
9210            listen_port: listen_addr.port(),
9211            download_rate_limit: 1024, // Very low: 1 KB/s
9212            ..test_config()
9213        };
9214
9215        drop(listener);
9216        let (atx, amask) = test_alert_channel();
9217        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9218        let handle = TorrentHandle::from_torrent(
9219            meta,
9220            irontide_core::TorrentVersion::V1Only,
9221            None,
9222            dh,
9223            dm,
9224            config,
9225            test_dht_rx(),
9226            test_dht_rx(),
9227            None,
9228            None,
9229            crate::slot_tuner::SlotTuner::disabled(4),
9230            atx,
9231            amask,
9232            None,
9233            None,
9234            test_ban_manager(),
9235            test_ip_filter(),
9236            Arc::new(Vec::new()),
9237            None,
9238            None,
9239            Arc::new(crate::transport::NetworkFactory::tokio()),
9240            None, // M96: hash_pool
9241            Arc::new(crate::stats::SessionCounters::new()),
9242        )
9243        .await
9244        .unwrap();
9245
9246        tokio::time::sleep(Duration::from_millis(50)).await;
9247
9248        // Connect mock seeder
9249        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
9250        let (reader, writer) = tokio::io::split(stream);
9251        let mut writer = writer;
9252        let mut reader = reader;
9253
9254        let hs = Handshake::new(
9255            info_hash,
9256            Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
9257        );
9258        writer.write_all(&hs.to_bytes()).await.unwrap();
9259        writer.flush().await.unwrap();
9260        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
9261        reader.read_exact(&mut hs_buf).await.unwrap();
9262
9263        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
9264        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
9265
9266        // Read ext handshake
9267        let _msg = framed_read.next().await;
9268        let ext_hs = ExtHandshake::new();
9269        let payload = ext_hs.to_bytes().unwrap();
9270        framed_write
9271            .send(Message::Extended { ext_id: 0, payload })
9272            .await
9273            .unwrap();
9274
9275        // Send bitfield saying we have all pieces (act as seeder)
9276        let mut bf = Bitfield::new(2);
9277        bf.set(0);
9278        bf.set(1);
9279        framed_write
9280            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
9281            .await
9282            .unwrap();
9283
9284        // Unchoke the torrent
9285        framed_write.send(Message::Unchoke).await.unwrap();
9286
9287        // Count Request messages received within 500ms.
9288        // With 1 KB/s download limit, the bucket only accumulates ~50 bytes
9289        // per 100ms tick, far less than 16 KB needed for a full chunk request.
9290        let mut requests_received = 0u32;
9291        let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
9292        loop {
9293            match tokio::time::timeout(
9294                deadline.saturating_duration_since(tokio::time::Instant::now()),
9295                framed_read.next(),
9296            )
9297            .await
9298            {
9299                Ok(Some(Ok(Message::Request { .. }))) => {
9300                    requests_received += 1;
9301                }
9302                Ok(Some(Ok(_))) => {}
9303                _ => break,
9304            }
9305        }
9306
9307        let stats = handle.stats().await.unwrap();
9308        assert_eq!(stats.state, TorrentState::Downloading);
9309
9310        // With 1 KB/s download limit and 16 KB chunks, we should see very few
9311        // or no requests within 500ms (budget insufficient for even one chunk)
9312        assert!(
9313            requests_received <= 2,
9314            "with 1 KB/s limit, should get very few requests, got {requests_received}"
9315        );
9316
9317        handle.shutdown().await.unwrap();
9318    }
9319
9320    // ── Smart banning tests (M25) ────────────────────────────────────
9321
9322    #[test]
9323    fn piece_contributor_tracking() {
9324        use std::net::IpAddr;
9325        let mut contributors: HashMap<u32, HashSet<IpAddr>> = HashMap::new();
9326        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9327        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9328
9329        contributors.entry(0).or_default().insert(ip1);
9330        contributors.entry(0).or_default().insert(ip2);
9331        assert_eq!(contributors[&0].len(), 2);
9332        assert!(contributors[&0].contains(&ip1));
9333        assert!(contributors[&0].contains(&ip2));
9334
9335        // Clear on verify
9336        contributors.remove(&0);
9337        assert!(!contributors.contains_key(&0));
9338    }
9339
9340    #[test]
9341    fn parole_enter_on_hash_failure() {
9342        use crate::ban::ParoleState;
9343        use std::net::IpAddr;
9344
9345        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9346        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9347        let contributors = vec![ip1, ip2];
9348
9349        // Simulate entering parole
9350        let parole = ParoleState {
9351            original_contributors: contributors.into_iter().collect(),
9352            parole_peer: None,
9353        };
9354
9355        assert_eq!(parole.original_contributors.len(), 2);
9356        assert!(parole.original_contributors.contains(&ip1));
9357        assert!(parole.original_contributors.contains(&ip2));
9358        assert!(parole.parole_peer.is_none());
9359    }
9360
9361    #[test]
9362    fn parole_success_strikes_originals() {
9363        use crate::ban::{BanConfig, BanManager, ParoleState};
9364        use std::net::IpAddr;
9365
9366        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9367        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9368        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9369
9370        let mut mgr = BanManager::new(BanConfig {
9371            max_failures: 2,
9372            use_parole: true,
9373        });
9374
9375        let parole = ParoleState {
9376            original_contributors: [ip1, ip2].into_iter().collect(),
9377            parole_peer: Some(parole_ip),
9378        };
9379
9380        // Simulate parole success: strike all originals
9381        for ip in &parole.original_contributors {
9382            mgr.record_strike(*ip);
9383        }
9384
9385        assert_eq!(*mgr.strikes_map().get(&ip1).unwrap(), 1);
9386        assert_eq!(*mgr.strikes_map().get(&ip2).unwrap(), 1);
9387        // Parole peer should not be struck
9388        assert!(!mgr.strikes_map().contains_key(&parole_ip));
9389
9390        // Second strike bans them
9391        for ip in &parole.original_contributors {
9392            mgr.record_strike(*ip);
9393        }
9394        assert!(mgr.is_banned(&ip1));
9395        assert!(mgr.is_banned(&ip2));
9396    }
9397
9398    #[test]
9399    fn parole_failure_strikes_parole_peer() {
9400        use crate::ban::{BanConfig, BanManager, ParoleState};
9401        use std::net::IpAddr;
9402
9403        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9404        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9405
9406        let mut mgr = BanManager::new(BanConfig {
9407            max_failures: 2,
9408            use_parole: true,
9409        });
9410
9411        let parole = ParoleState {
9412            original_contributors: [ip1].into_iter().collect(),
9413            parole_peer: Some(parole_ip),
9414        };
9415
9416        // Parole failure: strike the parole peer, not originals
9417        if let Some(pp) = parole.parole_peer {
9418            mgr.record_strike(pp);
9419        }
9420
9421        assert_eq!(*mgr.strikes_map().get(&parole_ip).unwrap(), 1);
9422        assert!(!mgr.strikes_map().contains_key(&ip1));
9423    }
9424
9425    #[tokio::test]
9426    async fn banned_peer_rejected_on_connect() {
9427        let data = vec![0xAB; 32768];
9428        let meta = make_test_torrent(&data, 16384);
9429        let storage = make_storage(&data, 16384);
9430        let config = test_config();
9431        let ban_mgr = test_ban_manager();
9432
9433        // Pre-ban an IP
9434        let banned_ip: std::net::IpAddr = "192.168.1.100".parse().unwrap();
9435        ban_mgr.write().ban(banned_ip);
9436
9437        let (atx, amask) = test_alert_channel();
9438        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9439        let handle = TorrentHandle::from_torrent(
9440            meta,
9441            irontide_core::TorrentVersion::V1Only,
9442            None,
9443            dh,
9444            dm,
9445            config,
9446            test_dht_rx(),
9447            test_dht_rx(),
9448            None,
9449            None,
9450            crate::slot_tuner::SlotTuner::disabled(4),
9451            atx,
9452            amask,
9453            None,
9454            None,
9455            Arc::clone(&ban_mgr),
9456            test_ip_filter(),
9457            Arc::new(Vec::new()),
9458            None,
9459            None,
9460            Arc::new(crate::transport::NetworkFactory::tokio()),
9461            None, // M96: hash_pool
9462            Arc::new(crate::stats::SessionCounters::new()),
9463        )
9464        .await
9465        .unwrap();
9466
9467        // Add the banned peer — it should be filtered out
9468        handle
9469            .add_peers(
9470                vec![
9471                    SocketAddr::new(banned_ip, 6881),
9472                    "10.0.0.1:6881".parse().unwrap(),
9473                ],
9474                PeerSource::Tracker,
9475            )
9476            .await
9477            .unwrap();
9478
9479        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9480        let stats = handle.stats().await.unwrap();
9481        // Only the non-banned peer should be in available pool (and may have connected)
9482        // The banned one should never appear
9483        assert!(
9484            stats.peers_available + stats.peers_connected <= 1,
9485            "banned peer should not be added: available={}, connected={}",
9486            stats.peers_available,
9487            stats.peers_connected
9488        );
9489
9490        handle.shutdown().await.unwrap();
9491    }
9492
9493    #[test]
9494    fn banned_peer_filtered_from_available() {
9495        use crate::ban::{BanConfig, BanManager};
9496        use std::net::IpAddr;
9497
9498        let banned_ip: IpAddr = "192.168.1.200".parse().unwrap();
9499        let ok_ip: IpAddr = "10.0.0.1".parse().unwrap();
9500
9501        let mgr = BanManager::new(BanConfig::default());
9502        // Not banned yet — both should pass
9503        assert!(!mgr.is_banned(&banned_ip));
9504        assert!(!mgr.is_banned(&ok_ip));
9505
9506        let mut mgr = BanManager::new(BanConfig::default());
9507        mgr.ban(banned_ip);
9508
9509        // Now banned_ip is filtered, ok_ip is not
9510        assert!(mgr.is_banned(&banned_ip));
9511        assert!(!mgr.is_banned(&ok_ip));
9512    }
9513
9514    // ---- M27: Parallel hashing tests ----
9515
9516    #[test]
9517    fn hashing_threads_config_default() {
9518        let s = irontide_settings::Settings::default();
9519        let expected = {
9520            let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
9521            (cores / 4).clamp(2, 8)
9522        };
9523        assert_eq!(s.hashing_threads, expected);
9524        let tc = TorrentConfig::default();
9525        assert_eq!(tc.hashing_threads, expected);
9526    }
9527
9528    #[tokio::test]
9529    async fn checking_state_and_progress_alerts() {
9530        use crate::alert::AlertKind;
9531
9532        let data = vec![0xEEu8; 65536]; // 4 pieces of 16384
9533        let meta = make_test_torrent(&data, 16384);
9534        let storage = make_seeded_storage(&data, 16384);
9535        let config = test_config();
9536
9537        let (atx, amask) = test_alert_channel();
9538        let mut rx = atx.subscribe();
9539        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9540        let handle = TorrentHandle::from_torrent(
9541            meta,
9542            irontide_core::TorrentVersion::V1Only,
9543            None,
9544            dh,
9545            dm,
9546            config,
9547            test_dht_rx(),
9548            test_dht_rx(),
9549            None,
9550            None,
9551            crate::slot_tuner::SlotTuner::disabled(4),
9552            atx,
9553            amask,
9554            None,
9555            None,
9556            test_ban_manager(),
9557            test_ip_filter(),
9558            Arc::new(Vec::new()),
9559            None,
9560            None,
9561            Arc::new(crate::transport::NetworkFactory::tokio()),
9562            None, // M96: hash_pool
9563            Arc::new(crate::stats::SessionCounters::new()),
9564        )
9565        .await
9566        .unwrap();
9567
9568        // Collect alerts for up to 2 seconds
9569        let mut saw_checking = false;
9570        let mut progress_values: Vec<f32> = Vec::new();
9571        let mut saw_checked = false;
9572        let mut checked_have = 0u32;
9573        let mut checked_total = 0u32;
9574
9575        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9576        while tokio::time::Instant::now() < deadline {
9577            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9578                Ok(Ok(alert)) => match alert.kind {
9579                    AlertKind::StateChanged {
9580                        new_state: TorrentState::Checking,
9581                        ..
9582                    } => {
9583                        saw_checking = true;
9584                    }
9585                    AlertKind::CheckingProgress { progress, .. } => {
9586                        progress_values.push(progress);
9587                    }
9588                    AlertKind::TorrentChecked {
9589                        pieces_have,
9590                        pieces_total,
9591                        ..
9592                    } => {
9593                        saw_checked = true;
9594                        checked_have = pieces_have;
9595                        checked_total = pieces_total;
9596                        break;
9597                    }
9598                    _ => {}
9599                },
9600                _ => break,
9601            }
9602        }
9603
9604        assert!(saw_checking, "should have seen StateChanged → Checking");
9605        assert!(
9606            !progress_values.is_empty(),
9607            "should have seen CheckingProgress alerts"
9608        );
9609        // Progress should be monotonically increasing
9610        for w in progress_values.windows(2) {
9611            assert!(
9612                w[1] >= w[0],
9613                "progress should be monotonically increasing: {} < {}",
9614                w[0],
9615                w[1]
9616            );
9617        }
9618        assert!(saw_checked, "should have seen TorrentChecked");
9619        assert_eq!(checked_have, 4);
9620        assert_eq!(checked_total, 4);
9621
9622        // Final state should be Seeding (all pieces valid)
9623        tokio::time::sleep(Duration::from_millis(50)).await;
9624        let stats = handle.stats().await.unwrap();
9625        assert_eq!(stats.state, TorrentState::Seeding);
9626
9627        handle.shutdown().await.unwrap();
9628    }
9629
9630    #[tokio::test]
9631    #[allow(clippy::float_cmp, reason = "exact sentinel value comparison (0.0)")]
9632    async fn checking_progress_in_stats() {
9633        // When not in Checking state, checking_progress should be 0.0
9634        let data = vec![0xAB; 32768];
9635        let meta = make_test_torrent(&data, 16384);
9636        let storage = make_storage(&data, 16384);
9637        let config = test_config();
9638
9639        let (atx, amask) = test_alert_channel();
9640        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9641        let handle = TorrentHandle::from_torrent(
9642            meta,
9643            irontide_core::TorrentVersion::V1Only,
9644            None,
9645            dh,
9646            dm,
9647            config,
9648            test_dht_rx(),
9649            test_dht_rx(),
9650            None,
9651            None,
9652            crate::slot_tuner::SlotTuner::disabled(4),
9653            atx,
9654            amask,
9655            None,
9656            None,
9657            test_ban_manager(),
9658            test_ip_filter(),
9659            Arc::new(Vec::new()),
9660            None,
9661            None,
9662            Arc::new(crate::transport::NetworkFactory::tokio()),
9663            None, // M96: hash_pool
9664            Arc::new(crate::stats::SessionCounters::new()),
9665        )
9666        .await
9667        .unwrap();
9668
9669        // Give actor time to finish checking (no valid pieces → Downloading)
9670        tokio::time::sleep(Duration::from_millis(100)).await;
9671
9672        let stats = handle.stats().await.unwrap();
9673        assert_eq!(stats.state, TorrentState::Downloading);
9674        assert_eq!(
9675            stats.checking_progress, 0.0,
9676            "checking_progress should be 0.0 when not checking"
9677        );
9678
9679        handle.shutdown().await.unwrap();
9680    }
9681
9682    #[tokio::test]
9683    async fn verify_pieces_partial_data() {
9684        use crate::alert::AlertKind;
9685
9686        // 4 pieces, only first 2 have valid data
9687        let data = vec![0xCCu8; 65536]; // 4 pieces × 16384
9688        let meta = make_test_torrent(&data, 16384);
9689
9690        // Create storage and only write valid data for pieces 0 and 1
9691        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9692        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
9693        for p in 0..2u32 {
9694            let offset = lengths.piece_offset(p) as usize;
9695            let size = lengths.piece_size(p) as usize;
9696            storage
9697                .write_chunk(p, 0, &data[offset..offset + size])
9698                .unwrap();
9699        }
9700        // Pieces 2 and 3 have no data (zeros) — won't match hash
9701
9702        let config = test_config();
9703        let (atx, amask) = test_alert_channel();
9704        let mut rx = atx.subscribe();
9705        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9706        let handle = TorrentHandle::from_torrent(
9707            meta,
9708            irontide_core::TorrentVersion::V1Only,
9709            None,
9710            dh,
9711            dm,
9712            config,
9713            test_dht_rx(),
9714            test_dht_rx(),
9715            None,
9716            None,
9717            crate::slot_tuner::SlotTuner::disabled(4),
9718            atx,
9719            amask,
9720            None,
9721            None,
9722            test_ban_manager(),
9723            test_ip_filter(),
9724            Arc::new(Vec::new()),
9725            None,
9726            None,
9727            Arc::new(crate::transport::NetworkFactory::tokio()),
9728            None, // M96: hash_pool
9729            Arc::new(crate::stats::SessionCounters::new()),
9730        )
9731        .await
9732        .unwrap();
9733
9734        // Wait for TorrentChecked alert
9735        let mut checked_have = 0u32;
9736        let mut checked_total = 0u32;
9737        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9738        while tokio::time::Instant::now() < deadline {
9739            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9740                Ok(Ok(alert)) => {
9741                    if let AlertKind::TorrentChecked {
9742                        pieces_have,
9743                        pieces_total,
9744                        ..
9745                    } = alert.kind
9746                    {
9747                        checked_have = pieces_have;
9748                        checked_total = pieces_total;
9749                        break;
9750                    }
9751                }
9752                _ => break,
9753            }
9754        }
9755
9756        assert_eq!(checked_have, 2, "only 2 pieces should be valid");
9757        assert_eq!(checked_total, 4);
9758
9759        // Final state should be Downloading (partial)
9760        tokio::time::sleep(Duration::from_millis(50)).await;
9761        let stats = handle.stats().await.unwrap();
9762        assert_eq!(stats.state, TorrentState::Downloading);
9763        assert_eq!(stats.pieces_have, 2);
9764        assert_eq!(stats.pieces_total, 4);
9765
9766        handle.shutdown().await.unwrap();
9767    }
9768
9769    // ---- M29: IP filter integration tests ----
9770
9771    #[tokio::test]
9772    async fn ip_filter_blocks_peers_in_handle_add_peers() {
9773        let data = vec![0xCD; 32768];
9774        let meta = make_test_torrent(&data, 16384);
9775        let storage = make_storage(&data, 16384);
9776        let config = test_config();
9777
9778        // Create an IP filter that blocks 203.0.113.0/24 (TEST-NET-3, public range)
9779        let ip_filter = {
9780            let mut f = crate::ip_filter::IpFilter::new();
9781            f.add_rule(
9782                "203.0.113.0".parse().unwrap(),
9783                "203.0.113.255".parse().unwrap(),
9784                1,
9785            );
9786            Arc::new(parking_lot::RwLock::new(f))
9787        };
9788
9789        let (atx, amask) = test_alert_channel();
9790        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9791        let handle = TorrentHandle::from_torrent(
9792            meta,
9793            irontide_core::TorrentVersion::V1Only,
9794            None,
9795            dh,
9796            dm,
9797            config,
9798            test_dht_rx(),
9799            test_dht_rx(),
9800            None,
9801            None,
9802            crate::slot_tuner::SlotTuner::disabled(4),
9803            atx,
9804            amask,
9805            None,
9806            None,
9807            test_ban_manager(),
9808            Arc::clone(&ip_filter),
9809            Arc::new(Vec::new()),
9810            None,
9811            None,
9812            Arc::new(crate::transport::NetworkFactory::tokio()),
9813            None, // M96: hash_pool
9814            Arc::new(crate::stats::SessionCounters::new()),
9815        )
9816        .await
9817        .unwrap();
9818
9819        // Add peers: one blocked (public IP in TEST-NET-3), one allowed (different public IP)
9820        let blocked_addr: SocketAddr = "203.0.113.42:6881".parse().unwrap();
9821        let allowed_addr: SocketAddr = "198.51.100.1:6881".parse().unwrap();
9822        handle
9823            .add_peers(vec![blocked_addr, allowed_addr], PeerSource::Tracker)
9824            .await
9825            .unwrap();
9826
9827        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9828        let stats = handle.stats().await.unwrap();
9829        // Only the allowed peer should be in the pool
9830        assert!(
9831            stats.peers_available + stats.peers_connected <= 1,
9832            "blocked peer should not be added: available={}, connected={}",
9833            stats.peers_available,
9834            stats.peers_connected
9835        );
9836
9837        handle.shutdown().await.unwrap();
9838    }
9839
9840    #[tokio::test]
9841    async fn set_ip_filter_replaces_filter_and_blocks_new_ip() {
9842        // Test that updating the shared IP filter takes effect for new peer additions.
9843        // Use public IPs (TEST-NET ranges) since local networks are always exempt.
9844        let data = vec![0xCD; 32768];
9845        let meta = make_test_torrent(&data, 16384);
9846        let storage = make_storage(&data, 16384);
9847        let config = test_config();
9848
9849        // Start with empty filter (everything allowed)
9850        let ip_filter: irontide_session_types::SharedIpFilter =
9851            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
9852
9853        let (atx, amask) = test_alert_channel();
9854        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9855        let handle = TorrentHandle::from_torrent(
9856            meta,
9857            irontide_core::TorrentVersion::V1Only,
9858            None,
9859            dh,
9860            dm,
9861            config,
9862            test_dht_rx(),
9863            test_dht_rx(),
9864            None,
9865            None,
9866            crate::slot_tuner::SlotTuner::disabled(4),
9867            atx,
9868            amask,
9869            None,
9870            None,
9871            test_ban_manager(),
9872            Arc::clone(&ip_filter),
9873            Arc::new(Vec::new()),
9874            None,
9875            None,
9876            Arc::new(crate::transport::NetworkFactory::tokio()),
9877            None, // M96: hash_pool
9878            Arc::new(crate::stats::SessionCounters::new()),
9879        )
9880        .await
9881        .unwrap();
9882
9883        // Initially, peers are allowed by the IP filter.
9884        // Use a local listener so the connection succeeds and the peer stays known.
9885        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9886        let local_addr = listener.local_addr().unwrap();
9887        handle
9888            .add_peers(vec![local_addr], PeerSource::Tracker)
9889            .await
9890            .unwrap();
9891        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9892        let stats = handle.stats().await.unwrap();
9893        assert!(
9894            stats.peers_available + stats.peers_connected >= 1,
9895            "peer should be allowed initially"
9896        );
9897        handle.shutdown().await.unwrap();
9898
9899        // Now update the shared filter to block that IP range
9900        {
9901            let mut f = ip_filter.write();
9902            f.add_rule(
9903                "198.51.100.0".parse().unwrap(),
9904                "198.51.100.255".parse().unwrap(),
9905                1,
9906            );
9907        }
9908
9909        // Verify the filter is updated (public IP, so is_blocked applies)
9910        assert!(ip_filter.read().is_blocked("198.51.100.1".parse().unwrap()));
9911        // Verify a different public IP is still allowed
9912        assert!(!ip_filter.read().is_blocked("203.0.113.1".parse().unwrap()));
9913    }
9914
9915    #[test]
9916    fn relocate_files_moves_and_cleans_up() {
9917        let tmp = std::env::temp_dir().join(format!("torrent_relocate_{}", std::process::id()));
9918        let src = tmp.join("src");
9919        let dst = tmp.join("dst");
9920
9921        // Create source files mimicking multi-file torrent layout:
9922        // TorrentName/subdir/file1.txt
9923        // TorrentName/file2.txt
9924        let subdir = src.join("TorrentName").join("subdir");
9925        std::fs::create_dir_all(&subdir).unwrap();
9926        std::fs::write(subdir.join("file1.txt"), b"hello").unwrap();
9927        std::fs::write(src.join("TorrentName").join("file2.txt"), b"world").unwrap();
9928
9929        let file_paths = vec![
9930            std::path::PathBuf::from("TorrentName/subdir/file1.txt"),
9931            std::path::PathBuf::from("TorrentName/file2.txt"),
9932        ];
9933
9934        relocate_files(&src, &dst, &file_paths).unwrap();
9935
9936        // Destination should have both files
9937        assert_eq!(
9938            std::fs::read_to_string(dst.join("TorrentName/subdir/file1.txt")).unwrap(),
9939            "hello"
9940        );
9941        assert_eq!(
9942            std::fs::read_to_string(dst.join("TorrentName/file2.txt")).unwrap(),
9943            "world"
9944        );
9945
9946        // Source directory should be cleaned up (empty dirs removed)
9947        assert!(!src.join("TorrentName").join("subdir").exists());
9948        assert!(!src.join("TorrentName").exists());
9949
9950        // Cleanup
9951        let _ = std::fs::remove_dir_all(&tmp);
9952    }
9953
9954    #[test]
9955    fn relocate_files_skips_missing() {
9956        let tmp =
9957            std::env::temp_dir().join(format!("torrent_relocate_skip_{}", std::process::id()));
9958        let src = tmp.join("src");
9959        let dst = tmp.join("dst");
9960        std::fs::create_dir_all(&src).unwrap();
9961
9962        // File doesn't exist — should be skipped without error
9963        let file_paths = vec![std::path::PathBuf::from("nonexistent.txt")];
9964        relocate_files(&src, &dst, &file_paths).unwrap();
9965
9966        assert!(!dst.join("nonexistent.txt").exists());
9967
9968        let _ = std::fs::remove_dir_all(&tmp);
9969    }
9970
9971    // ---- Test: force_recheck transitions through Checking state ----
9972
9973    #[tokio::test]
9974    async fn force_recheck_transitions_to_checking() {
9975        let data = vec![0xDDu8; 32768]; // 2 pieces
9976        let meta = make_test_torrent(&data, 16384);
9977        let storage = make_seeded_storage(&data, 16384);
9978        let config = test_config();
9979
9980        let (atx, amask) = test_alert_channel();
9981        let mut arx = atx.subscribe();
9982        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9983        let handle = TorrentHandle::from_torrent(
9984            meta,
9985            irontide_core::TorrentVersion::V1Only,
9986            None,
9987            dh,
9988            dm,
9989            config,
9990            test_dht_rx(),
9991            test_dht_rx(),
9992            None,
9993            None,
9994            crate::slot_tuner::SlotTuner::disabled(4),
9995            atx,
9996            amask,
9997            None,
9998            None,
9999            test_ban_manager(),
10000            test_ip_filter(),
10001            Arc::new(Vec::new()),
10002            None,
10003            None,
10004            Arc::new(crate::transport::NetworkFactory::tokio()),
10005            None, // M96: hash_pool
10006            Arc::new(crate::stats::SessionCounters::new()),
10007        )
10008        .await
10009        .unwrap();
10010
10011        // Wait for initial verification to complete (should become Seeding)
10012        tokio::time::sleep(Duration::from_millis(100)).await;
10013        let stats = handle.stats().await.unwrap();
10014        assert_eq!(stats.state, TorrentState::Seeding, "should start as seeder");
10015
10016        // Drain any existing alerts
10017        while arx.try_recv().is_ok() {}
10018
10019        // Force recheck
10020        handle.force_recheck().await.unwrap();
10021
10022        // After force_recheck returns, look for a StateChanged alert that
10023        // went through Checking (the transition_state fires it)
10024        let mut saw_checking = false;
10025        while let Ok(alert) = arx.try_recv() {
10026            if let crate::alert::AlertKind::StateChanged { new_state, .. } = alert.kind
10027                && new_state == TorrentState::Checking
10028            {
10029                saw_checking = true;
10030            }
10031        }
10032        assert!(
10033            saw_checking,
10034            "should have transitioned through Checking state"
10035        );
10036
10037        handle.shutdown().await.unwrap();
10038    }
10039
10040    // ---- Test: force_recheck completes with correct state ----
10041
10042    #[tokio::test]
10043    async fn force_recheck_completes() {
10044        let data = vec![0xEEu8; 32768]; // 2 pieces
10045        let meta = make_test_torrent(&data, 16384);
10046        let storage = make_seeded_storage(&data, 16384);
10047        let config = test_config();
10048
10049        let (atx, amask) = test_alert_channel();
10050        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10051        let handle = TorrentHandle::from_torrent(
10052            meta,
10053            irontide_core::TorrentVersion::V1Only,
10054            None,
10055            dh,
10056            dm,
10057            config,
10058            test_dht_rx(),
10059            test_dht_rx(),
10060            None,
10061            None,
10062            crate::slot_tuner::SlotTuner::disabled(4),
10063            atx,
10064            amask,
10065            None,
10066            None,
10067            test_ban_manager(),
10068            test_ip_filter(),
10069            Arc::new(Vec::new()),
10070            None,
10071            None,
10072            Arc::new(crate::transport::NetworkFactory::tokio()),
10073            None, // M96: hash_pool
10074            Arc::new(crate::stats::SessionCounters::new()),
10075        )
10076        .await
10077        .unwrap();
10078
10079        // Wait for initial verification
10080        tokio::time::sleep(Duration::from_millis(100)).await;
10081        let stats = handle.stats().await.unwrap();
10082        assert_eq!(stats.state, TorrentState::Seeding);
10083        assert_eq!(stats.pieces_have, 2);
10084
10085        // Force recheck — should re-verify all pieces and return to Seeding
10086        handle.force_recheck().await.unwrap();
10087
10088        let stats = handle.stats().await.unwrap();
10089        assert_eq!(
10090            stats.state,
10091            TorrentState::Seeding,
10092            "should return to Seeding after recheck"
10093        );
10094        assert_eq!(stats.pieces_have, 2, "all pieces should still be verified");
10095
10096        handle.shutdown().await.unwrap();
10097    }
10098
10099    // ---- Test: rename_file succeeds with valid index ----
10100
10101    #[tokio::test]
10102    async fn rename_file_succeeds() {
10103        // Create a real file on disk that we can rename
10104        let tmp = std::env::temp_dir().join(format!("torrent_rename_{}", std::process::id()));
10105        std::fs::create_dir_all(&tmp).unwrap();
10106
10107        let data = vec![0xFFu8; 16384]; // 1 piece
10108        let meta = make_test_torrent(&data, 16384);
10109        let storage = make_seeded_storage(&data, 16384);
10110
10111        // The single-file torrent has name "test", so file path is "test"
10112        // Create the actual file on disk at download_dir/test
10113        std::fs::write(tmp.join("test"), &data).unwrap();
10114
10115        let mut config = test_config();
10116        config.download_dir = tmp.clone();
10117
10118        let (atx, amask) = test_alert_channel();
10119        let mut arx = atx.subscribe();
10120        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10121        let handle = TorrentHandle::from_torrent(
10122            meta,
10123            irontide_core::TorrentVersion::V1Only,
10124            None,
10125            dh,
10126            dm,
10127            config,
10128            test_dht_rx(),
10129            test_dht_rx(),
10130            None,
10131            None,
10132            crate::slot_tuner::SlotTuner::disabled(4),
10133            atx,
10134            amask,
10135            None,
10136            None,
10137            test_ban_manager(),
10138            test_ip_filter(),
10139            Arc::new(Vec::new()),
10140            None,
10141            None,
10142            Arc::new(crate::transport::NetworkFactory::tokio()),
10143            None, // M96: hash_pool
10144            Arc::new(crate::stats::SessionCounters::new()),
10145        )
10146        .await
10147        .unwrap();
10148
10149        // Wait for initial verification
10150        tokio::time::sleep(Duration::from_millis(100)).await;
10151
10152        // Drain existing alerts
10153        while arx.try_recv().is_ok() {}
10154
10155        // Rename file 0 to "test_renamed"
10156        handle.rename_file(0, "test_renamed".into()).await.unwrap();
10157
10158        // Check that the old file is gone and new file exists
10159        assert!(!tmp.join("test").exists(), "old file should be removed");
10160        assert!(tmp.join("test_renamed").exists(), "new file should exist");
10161
10162        // Check that FileRenamed alert was fired
10163        let mut saw_renamed = false;
10164        while let Ok(alert) = arx.try_recv() {
10165            if let AlertKind::FileRenamed { index, .. } = alert.kind {
10166                assert_eq!(index, 0);
10167                saw_renamed = true;
10168            }
10169        }
10170        assert!(saw_renamed, "should have received FileRenamed alert");
10171
10172        handle.shutdown().await.unwrap();
10173        let _ = std::fs::remove_dir_all(&tmp);
10174    }
10175
10176    // ---- Test: rename_file with invalid index returns error ----
10177
10178    #[tokio::test]
10179    async fn rename_file_invalid_index_errors() {
10180        let data = vec![0xCCu8; 16384]; // 1 piece, single-file torrent
10181        let meta = make_test_torrent(&data, 16384);
10182        let storage = make_seeded_storage(&data, 16384);
10183        let config = test_config();
10184
10185        let (atx, amask) = test_alert_channel();
10186        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10187        let handle = TorrentHandle::from_torrent(
10188            meta,
10189            irontide_core::TorrentVersion::V1Only,
10190            None,
10191            dh,
10192            dm,
10193            config,
10194            test_dht_rx(),
10195            test_dht_rx(),
10196            None,
10197            None,
10198            crate::slot_tuner::SlotTuner::disabled(4),
10199            atx,
10200            amask,
10201            None,
10202            None,
10203            test_ban_manager(),
10204            test_ip_filter(),
10205            Arc::new(Vec::new()),
10206            None,
10207            None,
10208            Arc::new(crate::transport::NetworkFactory::tokio()),
10209            None, // M96: hash_pool
10210            Arc::new(crate::stats::SessionCounters::new()),
10211        )
10212        .await
10213        .unwrap();
10214
10215        // Wait for initial verification
10216        tokio::time::sleep(Duration::from_millis(100)).await;
10217
10218        // Try to rename file index 99 (out of range)
10219        let result = handle.rename_file(99, "bad".into()).await;
10220        assert!(result.is_err(), "should fail for out-of-range file index");
10221
10222        handle.shutdown().await.unwrap();
10223    }
10224
10225    // ---- Test: FileCompleted alert fires when all pieces of a file are verified ----
10226
10227    #[tokio::test]
10228    async fn file_completed_alert_fires() {
10229        let data = vec![0xBBu8; 32768]; // 2 pieces
10230        let meta = make_test_torrent(&data, 16384);
10231        let storage = make_seeded_storage(&data, 16384);
10232        let config = test_config();
10233
10234        let (atx, amask) = test_alert_channel();
10235        let mut arx = atx.subscribe();
10236        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10237        let handle = TorrentHandle::from_torrent(
10238            meta,
10239            irontide_core::TorrentVersion::V1Only,
10240            None,
10241            dh,
10242            dm,
10243            config,
10244            test_dht_rx(),
10245            test_dht_rx(),
10246            None,
10247            None,
10248            crate::slot_tuner::SlotTuner::disabled(4),
10249            atx,
10250            amask,
10251            None,
10252            None,
10253            test_ban_manager(),
10254            test_ip_filter(),
10255            Arc::new(Vec::new()),
10256            None,
10257            None,
10258            Arc::new(crate::transport::NetworkFactory::tokio()),
10259            None, // M96: hash_pool
10260            Arc::new(crate::stats::SessionCounters::new()),
10261        )
10262        .await
10263        .unwrap();
10264
10265        // Wait for initial verification (seeded storage => all pieces verify)
10266        tokio::time::sleep(Duration::from_millis(200)).await;
10267
10268        // Should have received FileCompleted alert for the single file
10269        let mut saw_file_completed = false;
10270        while let Ok(alert) = arx.try_recv() {
10271            if let AlertKind::FileCompleted { file_index, .. } = alert.kind {
10272                assert_eq!(file_index, 0, "should be file index 0");
10273                saw_file_completed = true;
10274            }
10275        }
10276        assert!(
10277            saw_file_completed,
10278            "should have received FileCompleted alert"
10279        );
10280
10281        handle.shutdown().await.unwrap();
10282    }
10283
10284    // ---- Test: MetadataFailed alert fires (unit test on AlertKind) ----
10285
10286    #[test]
10287    fn metadata_failed_alert_fires() {
10288        // Test that MetadataFailed alert has the correct category
10289        let info_hash = Id20::from([0u8; 20]);
10290        let alert = crate::alert::Alert::new(AlertKind::MetadataFailed { info_hash });
10291        assert!(
10292            alert
10293                .category()
10294                .contains(crate::alert::AlertCategory::STATUS),
10295            "MetadataFailed should have STATUS category"
10296        );
10297        assert!(
10298            alert
10299                .category()
10300                .contains(crate::alert::AlertCategory::ERROR),
10301            "MetadataFailed should have ERROR category"
10302        );
10303
10304        // Verify it can be posted through the alert system
10305        let (tx, mut rx) = broadcast::channel(16);
10306        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
10307        post_alert(&tx, &mask, AlertKind::MetadataFailed { info_hash });
10308        let received = rx.try_recv().expect("should receive MetadataFailed alert");
10309        assert!(matches!(received.kind, AlertKind::MetadataFailed { .. }));
10310    }
10311
10312    // ---- Test: set_max_connections persists ----
10313
10314    #[tokio::test]
10315    async fn set_max_connections_persists() {
10316        let data = vec![0xAB; 32768];
10317        let meta = make_test_torrent(&data, 16384);
10318        let storage = make_storage(&data, 16384);
10319        let config = test_config();
10320
10321        let (atx, amask) = test_alert_channel();
10322        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10323        let handle = TorrentHandle::from_torrent(
10324            meta,
10325            irontide_core::TorrentVersion::V1Only,
10326            None,
10327            dh,
10328            dm,
10329            config,
10330            test_dht_rx(),
10331            test_dht_rx(),
10332            None,
10333            None,
10334            crate::slot_tuner::SlotTuner::disabled(4),
10335            atx,
10336            amask,
10337            None,
10338            None,
10339            test_ban_manager(),
10340            test_ip_filter(),
10341            Arc::new(Vec::new()),
10342            None,
10343            None,
10344            Arc::new(crate::transport::NetworkFactory::tokio()),
10345            None, // M96: hash_pool
10346            Arc::new(crate::stats::SessionCounters::new()),
10347        )
10348        .await
10349        .unwrap();
10350
10351        // Set max_connections to 10
10352        handle.set_max_connections(10).await.unwrap();
10353        let val = handle.max_connections().await.unwrap();
10354        assert_eq!(val, 10);
10355
10356        // Update to a different value
10357        handle.set_max_connections(25).await.unwrap();
10358        let val = handle.max_connections().await.unwrap();
10359        assert_eq!(val, 25);
10360
10361        // Verify stats reflect the override
10362        let stats = handle.stats().await.unwrap();
10363        assert_eq!(stats.connections_limit, 25);
10364
10365        handle.shutdown().await.unwrap();
10366    }
10367
10368    // ---- Test: max_connections default is 0 (use config.max_peers) ----
10369
10370    #[tokio::test]
10371    async fn max_connections_default() {
10372        let data = vec![0xAB; 32768];
10373        let meta = make_test_torrent(&data, 16384);
10374        let storage = make_storage(&data, 16384);
10375        let config = test_config();
10376        let expected_default = config.max_peers;
10377
10378        let (atx, amask) = test_alert_channel();
10379        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10380        let handle = TorrentHandle::from_torrent(
10381            meta,
10382            irontide_core::TorrentVersion::V1Only,
10383            None,
10384            dh,
10385            dm,
10386            config,
10387            test_dht_rx(),
10388            test_dht_rx(),
10389            None,
10390            None,
10391            crate::slot_tuner::SlotTuner::disabled(4),
10392            atx,
10393            amask,
10394            None,
10395            None,
10396            test_ban_manager(),
10397            test_ip_filter(),
10398            Arc::new(Vec::new()),
10399            None,
10400            None,
10401            Arc::new(crate::transport::NetworkFactory::tokio()),
10402            None, // M96: hash_pool
10403            Arc::new(crate::stats::SessionCounters::new()),
10404        )
10405        .await
10406        .unwrap();
10407
10408        // Default max_connections should be 0
10409        let val = handle.max_connections().await.unwrap();
10410        assert_eq!(val, 0);
10411
10412        // Stats should show config.max_peers as the effective limit
10413        let stats = handle.stats().await.unwrap();
10414        assert_eq!(stats.connections_limit, expected_default);
10415
10416        handle.shutdown().await.unwrap();
10417    }
10418
10419    // ---- Test: set_max_uploads round trip ----
10420
10421    #[tokio::test]
10422    async fn set_max_uploads_round_trip() {
10423        let data = vec![0xAB; 32768];
10424        let meta = make_test_torrent(&data, 16384);
10425        let storage = make_storage(&data, 16384);
10426        let config = test_config();
10427
10428        let (atx, amask) = test_alert_channel();
10429        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10430        let handle = TorrentHandle::from_torrent(
10431            meta,
10432            irontide_core::TorrentVersion::V1Only,
10433            None,
10434            dh,
10435            dm,
10436            config,
10437            test_dht_rx(),
10438            test_dht_rx(),
10439            None,
10440            None,
10441            crate::slot_tuner::SlotTuner::disabled(4),
10442            atx,
10443            amask,
10444            None,
10445            None,
10446            test_ban_manager(),
10447            test_ip_filter(),
10448            Arc::new(Vec::new()),
10449            None,
10450            None,
10451            Arc::new(crate::transport::NetworkFactory::tokio()),
10452            None, // M96: hash_pool
10453            Arc::new(crate::stats::SessionCounters::new()),
10454        )
10455        .await
10456        .unwrap();
10457
10458        // Set max_uploads to 8
10459        handle.set_max_uploads(8).await.unwrap();
10460        let val = handle.max_uploads().await.unwrap();
10461        assert_eq!(val, 8);
10462
10463        // Verify stats uploads_limit reflects the new value
10464        let stats = handle.stats().await.unwrap();
10465        assert_eq!(stats.uploads_limit, 8);
10466
10467        handle.shutdown().await.unwrap();
10468    }
10469
10470    // ---- Test: ExternalIpDetected alert fires ----
10471
10472    #[tokio::test]
10473    async fn external_ip_detected_alert() {
10474        let data = vec![0xAB; 32768];
10475        let meta = make_test_torrent(&data, 16384);
10476        let storage = make_storage(&data, 16384);
10477        let config = test_config();
10478
10479        let (atx, amask) = test_alert_channel();
10480        let mut arx = atx.subscribe();
10481        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10482        let handle = TorrentHandle::from_torrent(
10483            meta,
10484            irontide_core::TorrentVersion::V1Only,
10485            None,
10486            dh,
10487            dm,
10488            config,
10489            test_dht_rx(),
10490            test_dht_rx(),
10491            None,
10492            None,
10493            crate::slot_tuner::SlotTuner::disabled(4),
10494            atx,
10495            amask,
10496            None,
10497            None,
10498            test_ban_manager(),
10499            test_ip_filter(),
10500            Arc::new(Vec::new()),
10501            None,
10502            None,
10503            Arc::new(crate::transport::NetworkFactory::tokio()),
10504            None, // M96: hash_pool
10505            Arc::new(crate::stats::SessionCounters::new()),
10506        )
10507        .await
10508        .unwrap();
10509
10510        // Drain any initial alerts
10511        while arx.try_recv().is_ok() {}
10512
10513        // Send UpdateExternalIp command
10514        let test_ip: std::net::IpAddr = "203.0.113.42".parse().unwrap();
10515        handle
10516            .cmd_tx
10517            .send(TorrentCommand::UpdateExternalIp { ip: test_ip })
10518            .await
10519            .unwrap();
10520
10521        // Wait for the actor to process
10522        tokio::time::sleep(Duration::from_millis(50)).await;
10523
10524        // Check for ExternalIpDetected alert
10525        let mut saw_alert = false;
10526        while let Ok(alert) = arx.try_recv() {
10527            if let AlertKind::ExternalIpDetected { ip } = alert.kind {
10528                assert_eq!(ip, test_ip);
10529                saw_alert = true;
10530            }
10531        }
10532        assert!(saw_alert, "should have received ExternalIpDetected alert");
10533
10534        handle.shutdown().await.unwrap();
10535    }
10536
10537    // ---- Test: get_peer_info returns connected peers ----
10538
10539    #[tokio::test]
10540    async fn get_peer_info_returns_connected_peers() {
10541        let data = vec![0xAB; 65536]; // 64 KiB
10542        let meta = make_test_torrent(&data, 16384); // 4 pieces
10543        let storage = make_storage(&data, 16384);
10544        let config = test_config();
10545
10546        let (atx, amask) = test_alert_channel();
10547        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10548        let handle = TorrentHandle::from_torrent(
10549            meta.clone(),
10550            irontide_core::TorrentVersion::V1Only,
10551            None,
10552            dh,
10553            dm,
10554            config,
10555            test_dht_rx(),
10556            test_dht_rx(),
10557            None,
10558            None,
10559            crate::slot_tuner::SlotTuner::disabled(4),
10560            atx,
10561            amask,
10562            None,
10563            None,
10564            test_ban_manager(),
10565            test_ip_filter(),
10566            Arc::new(Vec::new()),
10567            None,
10568            None,
10569            Arc::new(crate::transport::NetworkFactory::tokio()),
10570            None, // M96: hash_pool
10571            Arc::new(crate::stats::SessionCounters::new()),
10572        )
10573        .await
10574        .unwrap();
10575
10576        // Set up a fake peer via TCP handshake
10577        let stats = handle.stats().await.unwrap();
10578        let listen_port = stats.peers_connected; // Initially 0
10579
10580        // Add a peer to the available pool and let the actor connect
10581        let peer_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
10582        let peer_addr = peer_listener.local_addr().unwrap();
10583
10584        handle
10585            .add_peers(vec![peer_addr], PeerSource::Tracker)
10586            .await
10587            .unwrap();
10588
10589        // Accept the connection and complete the handshake
10590        let accept_timeout =
10591            tokio::time::timeout(Duration::from_secs(2), peer_listener.accept()).await;
10592        if let Ok(Ok((mut stream, _))) = accept_timeout {
10593            // Read handshake
10594            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
10595            if tokio::time::timeout(Duration::from_millis(500), stream.read_exact(&mut hs_buf))
10596                .await
10597                .is_ok()
10598            {
10599                // Send back handshake
10600                let hs = Handshake::new(meta.info_hash, Id20::from([0xBB; 20]));
10601                let hs_bytes = hs.to_bytes();
10602                let _ = stream.write_all(&hs_bytes).await;
10603
10604                // Give the actor time to register the peer
10605                tokio::time::sleep(Duration::from_millis(200)).await;
10606
10607                // Now query peer info
10608                let peer_info = handle.get_peer_info().await.unwrap();
10609                // We should have at least one peer (the one we just handshaked)
10610                if !peer_info.is_empty() {
10611                    let p = &peer_info[0];
10612                    // Verify default choking/interested state
10613                    assert!(p.peer_choking, "peer should be choking us initially");
10614                    // M107: we unconditionally unchoke on connect, so am_choking starts false
10615                    assert!(
10616                        !p.am_choking,
10617                        "we should not be choking peer after connect (M107 unconditional unchoke)"
10618                    );
10619                    assert!(
10620                        !p.peer_interested,
10621                        "peer should not be interested initially"
10622                    );
10623                    assert_eq!(p.num_pieces, 0);
10624                    assert_eq!(p.source, PeerSource::Tracker);
10625                }
10626            }
10627        }
10628        // Even if handshake timing fails, at least verify the API works
10629        let _ = handle.get_peer_info().await.unwrap();
10630        assert_eq!(listen_port, 0); // sanity: initially had no peers
10631
10632        handle.shutdown().await.unwrap();
10633    }
10634
10635    // ---- Test: get_peer_info empty when no peers ----
10636
10637    #[tokio::test]
10638    async fn get_peer_info_empty_when_no_peers() {
10639        let data = vec![0xAB; 32768];
10640        let meta = make_test_torrent(&data, 16384);
10641        let storage = make_storage(&data, 16384);
10642        let config = test_config();
10643
10644        let (atx, amask) = test_alert_channel();
10645        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10646        let handle = TorrentHandle::from_torrent(
10647            meta,
10648            irontide_core::TorrentVersion::V1Only,
10649            None,
10650            dh,
10651            dm,
10652            config,
10653            test_dht_rx(),
10654            test_dht_rx(),
10655            None,
10656            None,
10657            crate::slot_tuner::SlotTuner::disabled(4),
10658            atx,
10659            amask,
10660            None,
10661            None,
10662            test_ban_manager(),
10663            test_ip_filter(),
10664            Arc::new(Vec::new()),
10665            None,
10666            None,
10667            Arc::new(crate::transport::NetworkFactory::tokio()),
10668            None, // M96: hash_pool
10669            Arc::new(crate::stats::SessionCounters::new()),
10670        )
10671        .await
10672        .unwrap();
10673
10674        let peer_info = handle.get_peer_info().await.unwrap();
10675        assert!(peer_info.is_empty(), "should have no peers initially");
10676
10677        handle.shutdown().await.unwrap();
10678    }
10679
10680    // ---- Test: get_download_queue empty initially ----
10681
10682    #[tokio::test]
10683    async fn get_download_queue_empty_initially() {
10684        let data = vec![0xAB; 32768];
10685        let meta = make_test_torrent(&data, 16384);
10686        let storage = make_storage(&data, 16384);
10687        let config = test_config();
10688
10689        let (atx, amask) = test_alert_channel();
10690        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10691        let handle = TorrentHandle::from_torrent(
10692            meta,
10693            irontide_core::TorrentVersion::V1Only,
10694            None,
10695            dh,
10696            dm,
10697            config,
10698            test_dht_rx(),
10699            test_dht_rx(),
10700            None,
10701            None,
10702            crate::slot_tuner::SlotTuner::disabled(4),
10703            atx,
10704            amask,
10705            None,
10706            None,
10707            test_ban_manager(),
10708            test_ip_filter(),
10709            Arc::new(Vec::new()),
10710            None,
10711            None,
10712            Arc::new(crate::transport::NetworkFactory::tokio()),
10713            None, // M96: hash_pool
10714            Arc::new(crate::stats::SessionCounters::new()),
10715        )
10716        .await
10717        .unwrap();
10718
10719        let queue = handle.get_download_queue().await.unwrap();
10720        assert!(
10721            queue.is_empty(),
10722            "download queue should be empty with no active downloads"
10723        );
10724
10725        handle.shutdown().await.unwrap();
10726    }
10727
10728    // ---- Test: have_piece false initially ----
10729
10730    #[tokio::test]
10731    async fn have_piece_false_initially() {
10732        let data = vec![0xAB; 32768]; // 32 KiB = 2 pieces
10733        let meta = make_test_torrent(&data, 16384);
10734        let storage = make_storage(&data, 16384);
10735        let config = test_config();
10736
10737        let (atx, amask) = test_alert_channel();
10738        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10739        let handle = TorrentHandle::from_torrent(
10740            meta,
10741            irontide_core::TorrentVersion::V1Only,
10742            None,
10743            dh,
10744            dm,
10745            config,
10746            test_dht_rx(),
10747            test_dht_rx(),
10748            None,
10749            None,
10750            crate::slot_tuner::SlotTuner::disabled(4),
10751            atx,
10752            amask,
10753            None,
10754            None,
10755            test_ban_manager(),
10756            test_ip_filter(),
10757            Arc::new(Vec::new()),
10758            None,
10759            None,
10760            Arc::new(crate::transport::NetworkFactory::tokio()),
10761            None, // M96: hash_pool
10762            Arc::new(crate::stats::SessionCounters::new()),
10763        )
10764        .await
10765        .unwrap();
10766
10767        assert!(
10768            !handle.have_piece(0).await.unwrap(),
10769            "piece 0 should not be downloaded initially"
10770        );
10771        assert!(
10772            !handle.have_piece(1).await.unwrap(),
10773            "piece 1 should not be downloaded initially"
10774        );
10775
10776        handle.shutdown().await.unwrap();
10777    }
10778
10779    // ---- Test: piece_availability empty with no peers ----
10780
10781    #[tokio::test]
10782    async fn piece_availability_empty_no_peers() {
10783        let data = vec![0xAB; 32768]; // 2 pieces
10784        let meta = make_test_torrent(&data, 16384);
10785        let storage = make_storage(&data, 16384);
10786        let config = test_config();
10787
10788        let (atx, amask) = test_alert_channel();
10789        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10790        let handle = TorrentHandle::from_torrent(
10791            meta,
10792            irontide_core::TorrentVersion::V1Only,
10793            None,
10794            dh,
10795            dm,
10796            config,
10797            test_dht_rx(),
10798            test_dht_rx(),
10799            None,
10800            None,
10801            crate::slot_tuner::SlotTuner::disabled(4),
10802            atx,
10803            amask,
10804            None,
10805            None,
10806            test_ban_manager(),
10807            test_ip_filter(),
10808            Arc::new(Vec::new()),
10809            None,
10810            None,
10811            Arc::new(crate::transport::NetworkFactory::tokio()),
10812            None, // M96: hash_pool
10813            Arc::new(crate::stats::SessionCounters::new()),
10814        )
10815        .await
10816        .unwrap();
10817
10818        let avail = handle.piece_availability().await.unwrap();
10819        assert_eq!(avail.len(), 2, "should have availability for 2 pieces");
10820        assert!(
10821            avail.iter().all(|&c| c == 0),
10822            "all availability counts should be 0 with no peers"
10823        );
10824
10825        handle.shutdown().await.unwrap();
10826    }
10827
10828    // ---- Test: file_progress zeros initially ----
10829
10830    #[tokio::test]
10831    async fn file_progress_zeros_initially() {
10832        let data = vec![0xAB; 32768]; // single-file, 2 pieces
10833        let meta = make_test_torrent(&data, 16384);
10834        let storage = make_storage(&data, 16384);
10835        let config = test_config();
10836
10837        let (atx, amask) = test_alert_channel();
10838        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10839        let handle = TorrentHandle::from_torrent(
10840            meta,
10841            irontide_core::TorrentVersion::V1Only,
10842            None,
10843            dh,
10844            dm,
10845            config,
10846            test_dht_rx(),
10847            test_dht_rx(),
10848            None,
10849            None,
10850            crate::slot_tuner::SlotTuner::disabled(4),
10851            atx,
10852            amask,
10853            None,
10854            None,
10855            test_ban_manager(),
10856            test_ip_filter(),
10857            Arc::new(Vec::new()),
10858            None,
10859            None,
10860            Arc::new(crate::transport::NetworkFactory::tokio()),
10861            None, // M96: hash_pool
10862            Arc::new(crate::stats::SessionCounters::new()),
10863        )
10864        .await
10865        .unwrap();
10866
10867        let progress = handle.file_progress().await.unwrap();
10868        assert_eq!(progress.len(), 1, "single-file torrent should have 1 entry");
10869        assert_eq!(progress[0], 0, "no bytes should be downloaded initially");
10870
10871        handle.shutdown().await.unwrap();
10872    }
10873
10874    // ---- Test: file_progress length matches file count (multi-file) ----
10875
10876    /// Build a multi-file `TorrentMetaV1` from a total data blob and file lengths.
10877    fn make_test_torrent_multi(
10878        data: &[u8],
10879        piece_length: u64,
10880        file_lengths: &[u64],
10881    ) -> TorrentMetaV1 {
10882        use serde::Serialize;
10883
10884        #[derive(Serialize)]
10885        struct FileE {
10886            length: u64,
10887            path: Vec<String>,
10888        }
10889
10890        #[derive(Serialize)]
10891        struct Info<'a> {
10892            name: &'a str,
10893            #[serde(rename = "piece length")]
10894            piece_length: u64,
10895            #[serde(with = "serde_bytes")]
10896            pieces: &'a [u8],
10897            files: Vec<FileE>,
10898        }
10899
10900        #[derive(Serialize)]
10901        struct Torrent<'a> {
10902            info: Info<'a>,
10903        }
10904
10905        let mut pieces = Vec::new();
10906        let mut offset = 0;
10907        while offset < data.len() {
10908            let end = (offset + piece_length as usize).min(data.len());
10909            let hash = irontide_core::sha1(&data[offset..end]);
10910            pieces.extend_from_slice(hash.as_bytes());
10911            offset = end;
10912        }
10913
10914        let files: Vec<FileE> = file_lengths
10915            .iter()
10916            .enumerate()
10917            .map(|(i, &len)| FileE {
10918                length: len,
10919                path: vec![format!("file{i}.bin")],
10920            })
10921            .collect();
10922
10923        let t = Torrent {
10924            info: Info {
10925                name: "test_multi",
10926                piece_length,
10927                pieces: &pieces,
10928                files,
10929            },
10930        };
10931
10932        let bytes = irontide_bencode::to_bytes(&t).unwrap();
10933        torrent_from_bytes(&bytes).unwrap()
10934    }
10935
10936    #[tokio::test]
10937    async fn file_progress_length_matches_file_count() {
10938        // 3 files: 10000 + 20000 + 2768 = 32768 bytes total, 2 pieces of 16384
10939        let data = vec![0xCD; 32768];
10940        let file_lengths = [10000u64, 20000, 2768];
10941        let meta = make_test_torrent_multi(&data, 16384, &file_lengths);
10942        let storage = make_storage(&data, 16384);
10943        let config = test_config();
10944
10945        let (atx, amask) = test_alert_channel();
10946        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10947        let handle = TorrentHandle::from_torrent(
10948            meta,
10949            irontide_core::TorrentVersion::V1Only,
10950            None,
10951            dh,
10952            dm,
10953            config,
10954            test_dht_rx(),
10955            test_dht_rx(),
10956            None,
10957            None,
10958            crate::slot_tuner::SlotTuner::disabled(4),
10959            atx,
10960            amask,
10961            None,
10962            None,
10963            test_ban_manager(),
10964            test_ip_filter(),
10965            Arc::new(Vec::new()),
10966            None,
10967            None,
10968            Arc::new(crate::transport::NetworkFactory::tokio()),
10969            None, // M96: hash_pool
10970            Arc::new(crate::stats::SessionCounters::new()),
10971        )
10972        .await
10973        .unwrap();
10974
10975        let progress = handle.file_progress().await.unwrap();
10976        assert_eq!(
10977            progress.len(),
10978            3,
10979            "multi-file torrent should have 3 entries"
10980        );
10981        assert!(
10982            progress.iter().all(|&b| b == 0),
10983            "all progress should be 0 initially"
10984        );
10985
10986        handle.shutdown().await.unwrap();
10987    }
10988
10989    // ---- Test: is_valid returns true for active torrent ----
10990
10991    #[tokio::test]
10992    async fn is_valid_true_for_active() {
10993        let data = vec![0xAB; 32768];
10994        let meta = make_test_torrent(&data, 16384);
10995        let storage = make_storage(&data, 16384);
10996        let config = test_config();
10997
10998        let (atx, amask) = test_alert_channel();
10999        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11000        let handle = TorrentHandle::from_torrent(
11001            meta,
11002            irontide_core::TorrentVersion::V1Only,
11003            None,
11004            dh,
11005            dm,
11006            config,
11007            test_dht_rx(),
11008            test_dht_rx(),
11009            None,
11010            None,
11011            crate::slot_tuner::SlotTuner::disabled(4),
11012            atx,
11013            amask,
11014            None,
11015            None,
11016            test_ban_manager(),
11017            test_ip_filter(),
11018            Arc::new(Vec::new()),
11019            None,
11020            None,
11021            Arc::new(crate::transport::NetworkFactory::tokio()),
11022            None, // M96: hash_pool
11023            Arc::new(crate::stats::SessionCounters::new()),
11024        )
11025        .await
11026        .unwrap();
11027
11028        assert!(
11029            handle.is_valid(),
11030            "handle should be valid while torrent actor is alive"
11031        );
11032
11033        handle.shutdown().await.unwrap();
11034    }
11035
11036    // ---- Test: is_valid returns false after shutdown ----
11037
11038    #[tokio::test]
11039    async fn is_valid_false_after_remove() {
11040        let data = vec![0xAB; 32768];
11041        let meta = make_test_torrent(&data, 16384);
11042        let storage = make_storage(&data, 16384);
11043        let config = test_config();
11044
11045        let (atx, amask) = test_alert_channel();
11046        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11047        let handle = TorrentHandle::from_torrent(
11048            meta,
11049            irontide_core::TorrentVersion::V1Only,
11050            None,
11051            dh,
11052            dm,
11053            config,
11054            test_dht_rx(),
11055            test_dht_rx(),
11056            None,
11057            None,
11058            crate::slot_tuner::SlotTuner::disabled(4),
11059            atx,
11060            amask,
11061            None,
11062            None,
11063            test_ban_manager(),
11064            test_ip_filter(),
11065            Arc::new(Vec::new()),
11066            None,
11067            None,
11068            Arc::new(crate::transport::NetworkFactory::tokio()),
11069            None, // M96: hash_pool
11070            Arc::new(crate::stats::SessionCounters::new()),
11071        )
11072        .await
11073        .unwrap();
11074
11075        assert!(handle.is_valid());
11076
11077        // Shutdown the torrent (simulating removal)
11078        handle.shutdown().await.unwrap();
11079
11080        // Give the actor time to stop and close the channel
11081        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11082
11083        assert!(
11084            !handle.is_valid(),
11085            "handle should be invalid after shutdown"
11086        );
11087    }
11088
11089    // ---- Test: clear_error resets error state ----
11090
11091    #[tokio::test]
11092    async fn clear_error_resets() {
11093        let data = vec![0xAB; 32768];
11094        let meta = make_test_torrent(&data, 16384);
11095        let storage = make_storage(&data, 16384);
11096        let config = test_config();
11097
11098        let (atx, amask) = test_alert_channel();
11099        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11100        let handle = TorrentHandle::from_torrent(
11101            meta,
11102            irontide_core::TorrentVersion::V1Only,
11103            None,
11104            dh,
11105            dm,
11106            config,
11107            test_dht_rx(),
11108            test_dht_rx(),
11109            None,
11110            None,
11111            crate::slot_tuner::SlotTuner::disabled(4),
11112            atx,
11113            amask,
11114            None,
11115            None,
11116            test_ban_manager(),
11117            test_ip_filter(),
11118            Arc::new(Vec::new()),
11119            None,
11120            None,
11121            Arc::new(crate::transport::NetworkFactory::tokio()),
11122            None, // M96: hash_pool
11123            Arc::new(crate::stats::SessionCounters::new()),
11124        )
11125        .await
11126        .unwrap();
11127
11128        // Initially no error
11129        let stats = handle.stats().await.unwrap();
11130        assert!(stats.error.is_empty());
11131        assert_eq!(stats.error_file, -1);
11132
11133        // Clear error (no-op when no error) should succeed without issue
11134        handle.clear_error().await.unwrap();
11135
11136        let stats = handle.stats().await.unwrap();
11137        assert!(stats.error.is_empty());
11138        assert_eq!(stats.error_file, -1);
11139
11140        handle.shutdown().await.unwrap();
11141    }
11142
11143    // ---- Test: flags round trip ----
11144
11145    #[tokio::test]
11146    async fn flags_round_trip() {
11147        let data = vec![0xAB; 32768];
11148        let meta = make_test_torrent(&data, 16384);
11149        let storage = make_storage(&data, 16384);
11150        let config = test_config();
11151
11152        let (atx, amask) = test_alert_channel();
11153        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11154        let handle = TorrentHandle::from_torrent(
11155            meta,
11156            irontide_core::TorrentVersion::V1Only,
11157            None,
11158            dh,
11159            dm,
11160            config,
11161            test_dht_rx(),
11162            test_dht_rx(),
11163            None,
11164            None,
11165            crate::slot_tuner::SlotTuner::disabled(4),
11166            atx,
11167            amask,
11168            None,
11169            None,
11170            test_ban_manager(),
11171            test_ip_filter(),
11172            Arc::new(Vec::new()),
11173            None,
11174            None,
11175            Arc::new(crate::transport::NetworkFactory::tokio()),
11176            None, // M96: hash_pool
11177            Arc::new(crate::stats::SessionCounters::new()),
11178        )
11179        .await
11180        .unwrap();
11181
11182        // Initial flags: torrent starts downloading (not paused), no sequential, no super seeding
11183        let initial = handle.flags().await.unwrap();
11184        assert!(!initial.contains(crate::types::TorrentFlags::PAUSED));
11185        assert!(!initial.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
11186        assert!(!initial.contains(crate::types::TorrentFlags::SUPER_SEEDING));
11187
11188        // Enable sequential download via set_flags
11189        handle
11190            .set_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
11191            .await
11192            .unwrap();
11193        let after_set = handle.flags().await.unwrap();
11194        assert!(after_set.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
11195
11196        // Disable it via unset_flags
11197        handle
11198            .unset_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
11199            .await
11200            .unwrap();
11201        let after_unset = handle.flags().await.unwrap();
11202        assert!(!after_unset.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
11203
11204        // Verify sequential_download state via the dedicated query
11205        assert!(!handle.is_sequential_download().await.unwrap());
11206
11207        handle.shutdown().await.unwrap();
11208    }
11209
11210    // ---- Test: connect_peer does not error ----
11211
11212    #[tokio::test]
11213    async fn connect_peer_no_error() {
11214        let data = vec![0xAB; 32768];
11215        let meta = make_test_torrent(&data, 16384);
11216        let storage = make_storage(&data, 16384);
11217        let config = test_config();
11218
11219        let (atx, amask) = test_alert_channel();
11220        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11221        let handle = TorrentHandle::from_torrent(
11222            meta,
11223            irontide_core::TorrentVersion::V1Only,
11224            None,
11225            dh,
11226            dm,
11227            config,
11228            test_dht_rx(),
11229            test_dht_rx(),
11230            None,
11231            None,
11232            crate::slot_tuner::SlotTuner::disabled(4),
11233            atx,
11234            amask,
11235            None,
11236            None,
11237            test_ban_manager(),
11238            test_ip_filter(),
11239            Arc::new(Vec::new()),
11240            None,
11241            None,
11242            Arc::new(crate::transport::NetworkFactory::tokio()),
11243            None, // M96: hash_pool
11244            Arc::new(crate::stats::SessionCounters::new()),
11245        )
11246        .await
11247        .unwrap();
11248
11249        // connect_peer should not error even though the peer doesn't exist
11250        // (the connection attempt will fail asynchronously, but the command itself succeeds)
11251        let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap();
11252        handle.connect_peer(addr).await.unwrap();
11253
11254        // Give the actor a moment to process
11255        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
11256
11257        handle.shutdown().await.unwrap();
11258    }
11259
11260    // ---- BEP 52 hash serving tests (M87) ----
11261
11262    /// Build a minimal `TorrentMetaV2` with piece-layer hashes for testing.
11263    fn make_test_meta_v2(
11264        piece_hashes: &[irontide_core::Id32],
11265        file_root: irontide_core::Id32,
11266        piece_length: u64,
11267        file_length: u64,
11268    ) -> irontide_core::TorrentMetaV2 {
11269        use std::collections::BTreeMap;
11270
11271        // Concatenate piece hashes into raw bytes
11272        let mut layer_bytes = Vec::with_capacity(piece_hashes.len() * 32);
11273        for h in piece_hashes {
11274            layer_bytes.extend_from_slice(&h.0);
11275        }
11276
11277        let mut piece_layers = BTreeMap::new();
11278        piece_layers.insert(file_root, layer_bytes);
11279
11280        let file_tree = irontide_core::FileTreeNode::Directory({
11281            let mut children = BTreeMap::new();
11282            children.insert(
11283                "test.dat".to_string(),
11284                irontide_core::FileTreeNode::File(irontide_core::V2FileAttr {
11285                    length: file_length,
11286                    pieces_root: Some(file_root),
11287                }),
11288            );
11289            children
11290        });
11291
11292        irontide_core::TorrentMetaV2 {
11293            info_hashes: irontide_core::InfoHashes::v2_only(irontide_core::Id32::ZERO),
11294            info_bytes: None,
11295            announce: None,
11296            announce_list: None,
11297            comment: None,
11298            created_by: None,
11299            creation_date: None,
11300            info: irontide_core::InfoDictV2 {
11301                name: "test".to_string(),
11302                piece_length,
11303                meta_version: 2,
11304                file_tree,
11305                ssl_cert: None,
11306            },
11307            piece_layers,
11308            ssl_cert: None,
11309        }
11310    }
11311
11312    #[test]
11313    fn test_serve_hashes_v2_piece_layer() {
11314        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11315        // => blocks_per_piece = 1, piece_layer_base = 0
11316        let hashes: Vec<irontide_core::Id32> = (0..4u8)
11317            .map(|i| {
11318                let mut h = [0u8; 32];
11319                h[0] = i;
11320                irontide_core::Id32(h)
11321            })
11322            .collect();
11323        let file_root = irontide_core::Id32([0xAA; 32]);
11324        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11325        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11326
11327        let request = irontide_core::HashRequest {
11328            file_root,
11329            base: 0, // piece layer when blocks_per_piece = 1
11330            index: 0,
11331            count: 4,
11332            proof_layers: 0,
11333        };
11334
11335        let result = serve_hashes(
11336            Some(&meta),
11337            irontide_core::TorrentVersion::V2Only,
11338            Some(&lengths),
11339            &request,
11340        );
11341        let served = result.expect("should serve hashes");
11342        assert_eq!(served.len(), 4);
11343        for (i, h) in served.iter().enumerate() {
11344            assert_eq!(h.0[0], i as u8);
11345        }
11346    }
11347
11348    #[test]
11349    fn test_serve_hashes_rejects_v1_only() {
11350        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11351        let file_root = irontide_core::Id32([0xAA; 32]);
11352        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11353        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11354
11355        let request = irontide_core::HashRequest {
11356            file_root,
11357            base: 0,
11358            index: 0,
11359            count: 1,
11360            proof_layers: 0,
11361        };
11362
11363        let result = serve_hashes(
11364            Some(&meta),
11365            irontide_core::TorrentVersion::V1Only,
11366            Some(&lengths),
11367            &request,
11368        );
11369        assert!(result.is_none(), "V1Only should reject hash requests");
11370    }
11371
11372    #[test]
11373    fn test_serve_hashes_rejects_unknown_root() {
11374        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11375        let file_root = irontide_core::Id32([0xAA; 32]);
11376        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11377        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11378
11379        // Request a different file root that doesn't exist
11380        let unknown_root = irontide_core::Id32([0xFF; 32]);
11381        let request = irontide_core::HashRequest {
11382            file_root: unknown_root,
11383            base: 0,
11384            index: 0,
11385            count: 1,
11386            proof_layers: 0,
11387        };
11388
11389        let result = serve_hashes(
11390            Some(&meta),
11391            irontide_core::TorrentVersion::V2Only,
11392            Some(&lengths),
11393            &request,
11394        );
11395        assert!(result.is_none(), "unknown file_root should reject");
11396    }
11397
11398    #[test]
11399    fn test_serve_hashes_rejects_out_of_bounds() {
11400        // 2 piece hashes, piece_length = 16384, chunk_size = 16384
11401        let hashes: Vec<irontide_core::Id32> =
11402            (0..2u8).map(|i| irontide_core::Id32([i; 32])).collect();
11403        let file_root = irontide_core::Id32([0xAA; 32]);
11404        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 2);
11405        let lengths = Lengths::new(16384 * 2, 16384, DEFAULT_CHUNK_SIZE);
11406
11407        // Request starting at index 5, which is beyond the 2 available hashes
11408        let request = irontide_core::HashRequest {
11409            file_root,
11410            base: 0,
11411            index: 5,
11412            count: 1,
11413            proof_layers: 0,
11414        };
11415
11416        let result = serve_hashes(
11417            Some(&meta),
11418            irontide_core::TorrentVersion::V2Only,
11419            Some(&lengths),
11420            &request,
11421        );
11422        assert!(result.is_none(), "out-of-bounds index should reject");
11423    }
11424
11425    #[test]
11426    fn test_serve_hashes_includes_proofs() {
11427        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11428        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11429        let hashes: Vec<irontide_core::Id32> =
11430            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11431        let file_root = irontide_core::Id32([0xAA; 32]);
11432        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11433        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11434
11435        // Request 1 hash with 1 proof layer
11436        let request = irontide_core::HashRequest {
11437            file_root,
11438            base: 0,
11439            index: 0,
11440            count: 1,
11441            proof_layers: 1,
11442        };
11443
11444        let result = serve_hashes(
11445            Some(&meta),
11446            irontide_core::TorrentVersion::V2Only,
11447            Some(&lengths),
11448            &request,
11449        );
11450        let served = result.expect("should serve hashes with proofs");
11451        // 1 requested hash + 1 proof hash (sibling of leaf 0) = 2 total
11452        assert_eq!(served.len(), 2, "should have 1 data hash + 1 proof hash");
11453        // First hash is the requested piece hash
11454        assert_eq!(served[0], hashes[0]);
11455        // Second hash is the sibling (proof) — which is hashes[1]
11456        assert_eq!(served[1], hashes[1]);
11457    }
11458
11459    #[test]
11460    fn test_serve_hashes_proof_with_batch() {
11461        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11462        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11463        //
11464        // Tree layout (1-indexed heap):
11465        //          [1] root
11466        //        /          \
11467        //     [2]            [3]
11468        //    /    \         /    \
11469        //  [4]h0  [5]h1  [6]h2  [7]h3
11470        //
11471        // Request count=2 at index=0 => subtree rooted at [2] (h0, h1).
11472        // subtree_depth = log2(2) = 1, so we skip 1 level of the proof path.
11473        // proof_path(0) = [h1, hash(h2,h3)] — h1 is internal to subtree,
11474        // hash(h2,h3) is the uncle above. We skip h1 and send hash(h2,h3).
11475        let hashes: Vec<irontide_core::Id32> =
11476            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11477        let file_root = irontide_core::Id32([0xAA; 32]);
11478        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11479        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11480
11481        let request = irontide_core::HashRequest {
11482            file_root,
11483            base: 0,
11484            index: 0,
11485            count: 2,
11486            proof_layers: 1,
11487        };
11488
11489        let result = serve_hashes(
11490            Some(&meta),
11491            irontide_core::TorrentVersion::V2Only,
11492            Some(&lengths),
11493            &request,
11494        );
11495        let served = result.expect("should serve hashes with batch proof");
11496        // 2 base hashes + 1 uncle hash = 3 total
11497        assert_eq!(served.len(), 3, "should have 2 data hashes + 1 uncle hash");
11498        // First two are the requested piece hashes
11499        assert_eq!(served[0], hashes[0]);
11500        assert_eq!(served[1], hashes[1]);
11501        // Third is the uncle: sibling of the subtree root at [2],
11502        // which is the node at [3] = hash(h2, h3)
11503        let tree = irontide_core::MerkleTree::from_leaves(&hashes);
11504        let expected_uncle = tree.layer(1)[1]; // layer 1 has 2 nodes; index 1 is the right one
11505        assert_eq!(served[2], expected_uncle);
11506
11507        // Verify the proof is valid: reconstruct subtree root from base hashes,
11508        // then verify against the tree root using the uncle hash
11509        let sub_root = irontide_core::MerkleTree::root_from_hashes(&served[..2]);
11510        let uncle_hashes = &served[2..];
11511        let leaf_index = request.index as usize / 2; // 0 / 2 = 0
11512        assert!(
11513            irontide_core::MerkleTree::verify_proof(
11514                tree.root(),
11515                sub_root,
11516                leaf_index,
11517                uncle_hashes
11518            ),
11519            "subtree proof should verify against tree root"
11520        );
11521    }
11522
11523    #[test]
11524    fn is_i2p_synthetic_addr_detects_240_range() {
11525        assert!(is_i2p_synthetic_addr(&"240.0.0.1:1".parse().unwrap()));
11526        assert!(is_i2p_synthetic_addr(
11527            &"255.255.255.255:65535".parse().unwrap()
11528        ));
11529        assert!(!is_i2p_synthetic_addr(&"192.168.1.1:6881".parse().unwrap()));
11530        assert!(!is_i2p_synthetic_addr(&"[::1]:6881".parse().unwrap()));
11531    }
11532
11533    #[test]
11534    fn v6_retry_delay_progression() {
11535        // Verify exponential backoff: 100, 200, 400, 800, 1600, 3200, 5000, 5000...
11536        let expected_ms = [100, 200, 400, 800, 1600, 3200, 5000, 5000, 5000, 5000, 5000];
11537        for (count, &expected) in expected_ms.iter().enumerate() {
11538            let delay_ms = {
11539                let base_ms: u64 = 100;
11540                let max_ms: u64 = 5000;
11541                base_ms
11542                    .saturating_mul(1u64.checked_shl(count as u32).unwrap_or(u64::MAX))
11543                    .min(max_ms)
11544            };
11545            assert_eq!(
11546                delay_ms, expected,
11547                "count={count}: expected {expected}ms, got {delay_ms}ms"
11548            );
11549        }
11550    }
11551
11552    // ---- M104: Per-peer backoff and max_in_flight formula tests ----
11553
11554    #[test]
11555    fn peer_backoff_exponential() {
11556        // Verify the M104 backoff formula: 200ms * 2^attempt, capped at 30s.
11557        // attempt starts at 1 (first failure increments 0 → 1).
11558        let expected_ms: Vec<u64> = vec![400, 800, 1600, 3200, 6400, 12800, 25600, 30000, 30000];
11559        for (i, &expected) in expected_ms.iter().enumerate() {
11560            let attempt = (i as u32) + 1; // attempt counts start at 1
11561            let delay_ms = 200u64.saturating_mul(1u64 << attempt.min(10)).min(30_000);
11562            assert_eq!(
11563                delay_ms, expected,
11564                "attempt={attempt}: expected {expected}ms, got {delay_ms}ms"
11565            );
11566        }
11567    }
11568
11569    #[test]
11570    fn peer_backoff_clears_on_data() {
11571        // Verify that backoff map operations work correctly:
11572        // insert on disconnect, remove on data received.
11573        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11574        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11575
11576        // No backoff initially
11577        assert!(!backoff.contains_key(&addr));
11578
11579        // First disconnect: attempt 1
11580        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11581        let next = attempt.saturating_add(1);
11582        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11583        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11584        backoff.insert(addr, (earliest, next));
11585        assert_eq!(backoff.get(&addr).unwrap().1, 1);
11586
11587        // Second disconnect: attempt 2
11588        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11589        let next = attempt.saturating_add(1);
11590        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11591        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11592        backoff.insert(addr, (earliest, next));
11593        assert_eq!(backoff.get(&addr).unwrap().1, 2);
11594
11595        // Data received: clear
11596        backoff.remove(&addr);
11597        assert!(!backoff.contains_key(&addr));
11598    }
11599
11600    #[test]
11601    fn backoff_prevents_hammering() {
11602        // Verify that a peer with a future backoff time would be skipped.
11603        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11604        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11605
11606        // Set backoff 10 seconds in the future
11607        let future = std::time::Instant::now() + Duration::from_secs(10);
11608        backoff.insert(addr, (future, 3));
11609
11610        // Should be skipped (now < next_attempt)
11611        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11612            assert!(std::time::Instant::now() < next_attempt);
11613        }
11614
11615        // Set backoff in the past — should NOT be skipped
11616        let past = std::time::Instant::now() - Duration::from_secs(1);
11617        backoff.insert(addr, (past, 3));
11618        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11619            assert!(std::time::Instant::now() >= next_attempt);
11620        }
11621    }
11622
11623    #[test]
11624    fn max_in_flight_formula_updated() {
11625        // M104: max(512, connected*4) clamped to pieces/2, floored at 512.
11626        let formula = |connected: usize, num_pieces: u32| -> usize {
11627            let calculated = 512usize.max(connected.saturating_mul(4));
11628            calculated.min(num_pieces as usize / 2).max(512)
11629        };
11630
11631        // Few peers: floor dominates
11632        assert_eq!(formula(10, 2000), 512);
11633
11634        // Many peers: connected * 4 takes over
11635        assert_eq!(formula(200, 2000), 800);
11636
11637        // Very many peers: clamped by pieces/2
11638        assert_eq!(formula(500, 2000), 1000); // 2000 clamped to 1000
11639
11640        // Tiny torrent: floor dominates even with many peers
11641        assert_eq!(formula(200, 100), 512); // 800 clamped to 50, floored to 512
11642
11643        // Exact boundary
11644        assert_eq!(formula(128, 10000), 512); // 128*4=512, max(512,512)=512
11645        assert_eq!(formula(129, 10000), 516); // 129*4=516, max(512,516)=516
11646
11647        // Zero peers
11648        assert_eq!(formula(0, 10000), 512);
11649
11650        // Zero pieces (edge case — would give pieces/2=0, floor=512)
11651        assert_eq!(formula(100, 0), 512);
11652    }
11653
11654    // -- BEP 55 holepunch initiation tests (M112) --
11655
11656    #[test]
11657    fn should_attempt_holepunch_reason_classification() {
11658        // NAT-related reasons → true
11659        assert!(should_attempt_holepunch("connection refused"));
11660        assert!(should_attempt_holepunch("Connection refused"));
11661        assert!(should_attempt_holepunch("timed out"));
11662        assert!(should_attempt_holepunch("Connection reset by peer"));
11663        assert!(should_attempt_holepunch("connection reset by peer"));
11664        // Re-entrancy guard: holepunch-originated failures → false
11665        assert!(!should_attempt_holepunch(
11666            "holepunch TCP connect failed: Connection refused"
11667        ));
11668        // Non-NAT reasons → false
11669        assert!(!should_attempt_holepunch("peer banned"));
11670        assert!(!should_attempt_holepunch("protocol error"));
11671        assert!(!should_attempt_holepunch(""));
11672    }
11673
11674    #[test]
11675    fn holepunch_initiation_on_connect_failure() {
11676        // "connection refused" is the canonical NAT failure reason
11677        assert!(should_attempt_holepunch("connection refused"));
11678    }
11679
11680    #[test]
11681    fn holepunch_cooldown_prevents_retry() {
11682        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11683        let addr: SocketAddr = "127.0.0.1:6881".parse().expect("valid test addr");
11684        let now = Instant::now();
11685        cooldowns.insert(addr, now);
11686        // addr is in cooldowns, so should be skipped on subsequent attempt
11687        assert!(cooldowns.contains_key(&addr));
11688    }
11689
11690    #[test]
11691    fn holepunch_cooldown_overflow_skips() {
11692        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11693        let now = Instant::now();
11694        for i in 0..256u16 {
11695            let addr: SocketAddr = format!("10.0.{}.{}:6881", i / 256, i % 256)
11696                .parse()
11697                .expect("valid test addr");
11698            cooldowns.insert(addr, now);
11699        }
11700        assert_eq!(cooldowns.len(), HOLEPUNCH_MAX_TRACKED);
11701        // New entry should be skipped when at capacity
11702    }
11703
11704    #[test]
11705    fn holepunch_skipped_when_disabled() {
11706        // should_attempt_holepunch only checks the reason string, not config.
11707        // Config check happens in disconnect_peer.
11708        assert!(should_attempt_holepunch("connection refused"));
11709        // This test documents that should_attempt_holepunch is reason-only.
11710    }
11711
11712    #[test]
11713    fn holepunch_not_triggered_on_ban() {
11714        assert!(!should_attempt_holepunch("peer banned"));
11715        assert!(!should_attempt_holepunch("banned for bad data"));
11716    }
11717
11718    // -- M116: CachedFileInfo tests --
11719
11720    /// Helper to build a minimal `TorrentMetaV1` with multi-file entries.
11721    fn make_multi_file_meta(files: &[(u64, &str)], piece_length: u64) -> TorrentMetaV1 {
11722        let total_length: u64 = files.iter().map(|(len, _)| *len).sum();
11723        let num_pieces = total_length.div_ceil(piece_length) as usize;
11724        let file_entries: Vec<irontide_core::FileEntry> = files
11725            .iter()
11726            .map(|(length, name)| irontide_core::FileEntry {
11727                length: *length,
11728                path: vec![name.to_string()],
11729                attr: None,
11730                mtime: None,
11731                symlink_path: None,
11732            })
11733            .collect();
11734        TorrentMetaV1 {
11735            info_hash: Id20([0u8; 20]),
11736            announce: None,
11737            announce_list: None,
11738            comment: None,
11739            created_by: None,
11740            creation_date: None,
11741            info: irontide_core::InfoDict {
11742                name: "test".to_string(),
11743                piece_length,
11744                pieces: vec![0u8; num_pieces * 20],
11745                length: None,
11746                files: Some(file_entries),
11747                private: None,
11748                source: None,
11749                ssl_cert: None,
11750                similar: Vec::new(),
11751                collections: Vec::new(),
11752            },
11753            url_list: Vec::new(),
11754            httpseeds: Vec::new(),
11755            info_bytes: None,
11756            ssl_cert: None,
11757        }
11758    }
11759
11760    #[test]
11761    fn cached_files_populated_on_registration() {
11762        // 3 files: 100, 200, 50 bytes; piece_length = 100
11763        // Total = 350 bytes, 4 pieces (0..3)
11764        // File 0: offset 0..100  -> pieces [0, 0]
11765        // File 1: offset 100..300 -> pieces [1, 2]
11766        // File 2: offset 300..350 -> pieces [3, 3]
11767        let meta = make_multi_file_meta(&[(100, "a.txt"), (200, "b.txt"), (50, "c.txt")], 100);
11768        let lengths = Lengths::new(350, 100, 16384);
11769        let cached = build_cached_file_info(&meta, &lengths);
11770
11771        assert_eq!(cached.entries.len(), 3);
11772
11773        assert_eq!(cached.entries[0].index, 0);
11774        assert_eq!(cached.entries[0].length, 100);
11775        assert_eq!(cached.entries[0].first_piece, 0);
11776        assert_eq!(cached.entries[0].last_piece, 0);
11777
11778        assert_eq!(cached.entries[1].index, 1);
11779        assert_eq!(cached.entries[1].length, 200);
11780        assert_eq!(cached.entries[1].first_piece, 1);
11781        assert_eq!(cached.entries[1].last_piece, 2);
11782
11783        assert_eq!(cached.entries[2].index, 2);
11784        assert_eq!(cached.entries[2].length, 50);
11785        assert_eq!(cached.entries[2].first_piece, 3);
11786        assert_eq!(cached.entries[2].last_piece, 3);
11787    }
11788
11789    #[test]
11790    fn cached_files_single_file_torrent() {
11791        // Single-file torrent: 500 bytes, piece_length = 100
11792        // 5 pieces (0..4)
11793        let meta = TorrentMetaV1 {
11794            info_hash: Id20([0u8; 20]),
11795            announce: None,
11796            announce_list: None,
11797            comment: None,
11798            created_by: None,
11799            creation_date: None,
11800            info: irontide_core::InfoDict {
11801                name: "single.bin".to_string(),
11802                piece_length: 100,
11803                pieces: vec![0u8; 5 * 20],
11804                length: Some(500),
11805                files: None,
11806                private: None,
11807                source: None,
11808                ssl_cert: None,
11809                similar: Vec::new(),
11810                collections: Vec::new(),
11811            },
11812            url_list: Vec::new(),
11813            httpseeds: Vec::new(),
11814            info_bytes: None,
11815            ssl_cert: None,
11816        };
11817        let lengths = Lengths::new(500, 100, 16384);
11818        let cached = build_cached_file_info(&meta, &lengths);
11819
11820        assert_eq!(cached.entries.len(), 1);
11821        assert_eq!(cached.entries[0].index, 0);
11822        assert_eq!(cached.entries[0].length, 500);
11823        assert_eq!(cached.entries[0].first_piece, 0);
11824        assert_eq!(cached.entries[0].last_piece, 4);
11825    }
11826
11827    // ── M132: Time-based steal-queue population tests ──
11828    //
11829    // These tests verify the steal-populate logic that runs in run_steal_queue_maintenance().
11830    // They build AtomicPieceStates and StealCandidates directly and exercise the
11831    // same scan loop used by the real implementation.
11832
11833    use crate::piece_reservation::{AtomicPieceStates, PieceState, StealCandidates};
11834    use irontide_storage::Bitfield;
11835
11836    /// Helper: run the steal-populate scan (mirrors `run_steal_queue_maintenance`).
11837    ///
11838    /// Returns the number of pieces pushed into the steal queue.
11839    fn steal_populate_scan(states: &AtomicPieceStates, sc: &StealCandidates) -> u32 {
11840        let mut pushed = 0u32;
11841        let num = states.len();
11842        for piece in 0..num {
11843            let state = states.get(piece);
11844            if state == PieceState::Reserved {
11845                sc.push(piece);
11846                pushed = pushed.saturating_add(1);
11847            }
11848        }
11849        pushed
11850    }
11851
11852    fn all_wanted(n: u32) -> Bitfield {
11853        let mut bf = Bitfield::new(n);
11854        for i in 0..n {
11855            bf.set(i);
11856        }
11857        bf
11858    }
11859
11860    #[test]
11861    fn steal_populate_pushes_reserved_pieces() {
11862        let n = 10;
11863        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11864        let sc = StealCandidates::new();
11865
11866        // Reserve pieces 2, 5, 7
11867        assert!(states.try_reserve(2));
11868        assert!(states.try_reserve(5));
11869        assert!(states.try_reserve(7));
11870
11871        let pushed = steal_populate_scan(&states, &sc);
11872        assert_eq!(pushed, 3, "should push exactly the 3 reserved pieces");
11873
11874        // Verify they're in the queue
11875        let mut popped = Vec::new();
11876        while let Some(p) = sc.pop() {
11877            popped.push(p);
11878        }
11879        popped.sort_unstable();
11880        assert_eq!(popped, vec![2, 5, 7]);
11881    }
11882
11883    #[test]
11884    fn steal_populate_skips_non_reserved_states() {
11885        let n = 8;
11886        let mut have = Bitfield::new(n);
11887        have.set(0); // piece 0 = Complete
11888        let mut wanted = all_wanted(n);
11889        wanted.clear(1); // piece 1 = Unwanted
11890
11891        let states = AtomicPieceStates::new(n, &have, &wanted);
11892        let sc = StealCandidates::new();
11893
11894        // Reserve piece 3, leave rest as Available/Complete/Unwanted
11895        assert!(states.try_reserve(3));
11896
11897        let pushed = steal_populate_scan(&states, &sc);
11898        assert_eq!(pushed, 1, "only piece 3 (Reserved) should be pushed");
11899
11900        assert_eq!(sc.pop(), Some(3));
11901        assert_eq!(sc.pop(), None);
11902    }
11903
11904    #[test]
11905    fn steal_populate_deduplicates() {
11906        let n = 4;
11907        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11908        let sc = StealCandidates::new();
11909
11910        assert!(states.try_reserve(1));
11911        assert!(states.try_reserve(2));
11912
11913        // First scan pushes 2 pieces
11914        let pushed1 = steal_populate_scan(&states, &sc);
11915        assert_eq!(pushed1, 2);
11916
11917        // Second scan: StealCandidates.push() deduplicates, so the queue
11918        // should still contain exactly 2 entries, not 4.
11919        let pushed2 = steal_populate_scan(&states, &sc);
11920        assert_eq!(pushed2, 2, "scan still reports 2 reserved pieces");
11921
11922        let mut count = 0u32;
11923        while sc.pop().is_some() {
11924            count = count.saturating_add(1);
11925        }
11926        assert_eq!(count, 2, "dedup means only 2 entries despite 2 scans");
11927    }
11928
11929    #[test]
11930    fn steal_populate_skips_completed_pieces() {
11931        let n = 5;
11932        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11933        let sc = StealCandidates::new();
11934
11935        // Reserve all 5 pieces
11936        for i in 0..n {
11937            assert!(states.try_reserve(i));
11938        }
11939
11940        // Complete pieces 1 and 3 before the scan
11941        states.mark_complete(1);
11942        states.mark_complete(3);
11943
11944        let pushed = steal_populate_scan(&states, &sc);
11945        assert_eq!(pushed, 3, "3 pieces still Reserved (0, 2, 4)");
11946
11947        let mut popped = Vec::new();
11948        while let Some(p) = sc.pop() {
11949            popped.push(p);
11950        }
11951        popped.sort_unstable();
11952        assert_eq!(popped, vec![0, 2, 4]);
11953    }
11954
11955    #[test]
11956    fn steal_populate_empty_when_no_reserved() {
11957        let n = 6;
11958        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11959        let sc = StealCandidates::new();
11960
11961        // No pieces reserved — scan should push nothing
11962        let pushed = steal_populate_scan(&states, &sc);
11963        assert_eq!(pushed, 0);
11964        assert_eq!(sc.pop(), None);
11965    }
11966
11967    #[test]
11968    fn steal_populate_with_endgame_pieces() {
11969        // Endgame pieces (state = Endgame) should NOT be pushed — only Reserved.
11970        let n = 4;
11971        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11972        let sc = StealCandidates::new();
11973
11974        assert!(states.try_reserve(0));
11975        assert!(states.try_reserve(1));
11976        states.transition_to_endgame(1);
11977
11978        let pushed = steal_populate_scan(&states, &sc);
11979        assert_eq!(
11980            pushed, 1,
11981            "only piece 0 (Reserved) should be pushed, not piece 1 (Endgame)"
11982        );
11983        assert_eq!(sc.pop(), Some(0));
11984        assert_eq!(sc.pop(), None);
11985    }
11986
11987    // -------------------------------------------------------------------
11988    // F8: Piece state sync on file priority change
11989    // -------------------------------------------------------------------
11990
11991    #[test]
11992    fn sync_piece_states_marks_unwanted_on_skip() {
11993        let n = 8;
11994        let mut wanted = all_wanted(n);
11995        wanted.clear(2);
11996        wanted.clear(3);
11997        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11998        // Simulate: wanted_pieces was rebuilt but atomic_states not yet synced.
11999        // Pieces 2 and 3 are Available but no longer wanted.
12000        assert_eq!(states.get(2), PieceState::Available);
12001        assert_eq!(states.get(3), PieceState::Available);
12002
12003        // Run the sync logic directly.
12004        for piece in 0..n {
12005            let w = wanted.get(piece);
12006            let current = states.get(piece);
12007            if !w && current == PieceState::Available {
12008                states.mark_unwanted(piece);
12009            } else if w && current == PieceState::Unwanted {
12010                states.mark_available(piece);
12011            }
12012        }
12013
12014        assert_eq!(states.get(0), PieceState::Available);
12015        assert_eq!(states.get(2), PieceState::Unwanted);
12016        assert_eq!(states.get(3), PieceState::Unwanted);
12017        assert_eq!(states.get(4), PieceState::Available);
12018    }
12019
12020    #[test]
12021    fn sync_piece_states_restores_available_on_unskip() {
12022        let n = 6;
12023        let mut initial_wanted = all_wanted(n);
12024        initial_wanted.clear(1);
12025        initial_wanted.clear(4);
12026        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &initial_wanted);
12027        assert_eq!(states.get(1), PieceState::Unwanted);
12028        assert_eq!(states.get(4), PieceState::Unwanted);
12029
12030        // Now re-enable all pieces (simulate setting back to Normal).
12031        let new_wanted = all_wanted(n);
12032        for piece in 0..n {
12033            let w = new_wanted.get(piece);
12034            let current = states.get(piece);
12035            if !w && current == PieceState::Available {
12036                states.mark_unwanted(piece);
12037            } else if w && current == PieceState::Unwanted {
12038                states.mark_available(piece);
12039            }
12040        }
12041
12042        assert_eq!(states.get(1), PieceState::Available);
12043        assert_eq!(states.get(4), PieceState::Available);
12044    }
12045
12046    #[test]
12047    fn sync_piece_states_shared_piece_stays_available() {
12048        // A piece spanning a skipped and non-skipped file stays wanted
12049        // (build_wanted_pieces marks it wanted if any spanning file is
12050        // non-skip). Verify the sync leaves it Available.
12051        let n = 4;
12052        let mut wanted = all_wanted(n);
12053        wanted.clear(0); // exclusive to skipped file
12054        // Piece 1 is shared — stays wanted
12055        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
12056
12057        for piece in 0..n {
12058            let w = wanted.get(piece);
12059            let current = states.get(piece);
12060            if !w && current == PieceState::Available {
12061                states.mark_unwanted(piece);
12062            } else if w && current == PieceState::Unwanted {
12063                states.mark_available(piece);
12064            }
12065        }
12066
12067        assert_eq!(states.get(0), PieceState::Unwanted);
12068        assert_eq!(
12069            states.get(1),
12070            PieceState::Available,
12071            "shared piece stays Available"
12072        );
12073        assert_eq!(states.get(2), PieceState::Available);
12074        assert_eq!(states.get(3), PieceState::Available);
12075    }
12076
12077    // -------------------------------------------------------------------
12078    // M133: DHT re-query tests
12079    // -------------------------------------------------------------------
12080
12081    /// Verify the DHT re-query guard scales with `max_peers` config.
12082    ///
12083    /// The guard threshold is `max_peers * 4`. With default `max_peers = 128`,
12084    /// this becomes 512 (close to the old hardcoded 500).
12085    #[test]
12086    fn dht_requery_guard_scales_with_max_peers() {
12087        // max_peers = 128 → threshold = 512
12088        assert_eq!(128_usize.saturating_mul(4), 512);
12089
12090        // max_peers = 200 → threshold = 800
12091        assert_eq!(200_usize.saturating_mul(4), 800);
12092
12093        // max_peers = 50 → threshold = 200
12094        assert_eq!(50_usize.saturating_mul(4), 200);
12095
12096        // Overflow protection: saturating_mul handles usize::MAX
12097        assert_eq!(usize::MAX.saturating_mul(4), usize::MAX);
12098    }
12099
12100    // ---- M147: Pre-resolved metadata tests ----
12101
12102    /// Build a raw bencoded info dict and its SHA1 hash (for magnet link testing).
12103    fn make_test_info_bytes() -> (Vec<u8>, Id20) {
12104        use serde::Serialize;
12105
12106        #[derive(Serialize)]
12107        struct Info<'a> {
12108            length: u64,
12109            name: &'a str,
12110            #[serde(rename = "piece length")]
12111            piece_length: u64,
12112            #[serde(with = "serde_bytes")]
12113            pieces: &'a [u8],
12114        }
12115
12116        let data = vec![0xAB; 1024];
12117        let piece_hash = irontide_core::sha1(&data);
12118        let mut pieces = Vec::new();
12119        pieces.extend_from_slice(piece_hash.as_bytes());
12120
12121        let info = Info {
12122            length: 1024,
12123            name: "test",
12124            piece_length: 16384,
12125            pieces: &pieces,
12126        };
12127
12128        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
12129        let info_hash = irontide_core::sha1(&info_bytes);
12130        (info_bytes, info_hash)
12131    }
12132
12133    /// Create a magnet-based `TorrentHandle` for testing `PreResolvedMetadata`.
12134    async fn create_magnet_handle(info_hash: Id20) -> TorrentHandle {
12135        let magnet = Magnet {
12136            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
12137            display_name: Some("test".into()),
12138            trackers: vec![],
12139            peers: vec![],
12140            selected_files: None,
12141        };
12142        let config = test_config();
12143        let (atx, amask) = test_alert_channel();
12144        let (dm, _dj) = test_disk_manager();
12145        TorrentHandle::from_magnet(
12146            magnet,
12147            dm,
12148            config,
12149            test_dht_rx(),
12150            test_dht_rx(),
12151            None,
12152            None,
12153            crate::slot_tuner::SlotTuner::disabled(4),
12154            atx,
12155            amask,
12156            None,
12157            None,
12158            test_ban_manager(),
12159            test_ip_filter(),
12160            Arc::new(Vec::new()),
12161            None,
12162            None,
12163            Arc::new(crate::transport::NetworkFactory::tokio()),
12164            None,
12165            Arc::new(crate::stats::SessionCounters::new()),
12166        )
12167        .await
12168        .unwrap()
12169    }
12170
12171    #[tokio::test]
12172    async fn pre_resolved_metadata_applies_when_fetching() {
12173        let (info_bytes, info_hash) = make_test_info_bytes();
12174        let handle = create_magnet_handle(info_hash).await;
12175
12176        // Verify we start in FetchingMetadata state.
12177        let stats = handle.stats().await.unwrap();
12178        assert_eq!(stats.state, TorrentState::FetchingMetadata);
12179
12180        // Send pre-resolved metadata (with a fake peer for pre-seeding).
12181        let peer_addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
12182        handle.send_pre_resolved_metadata(info_bytes, vec![peer_addr]);
12183
12184        // Give the actor time to process the command.
12185        tokio::time::sleep(Duration::from_millis(200)).await;
12186
12187        // Verify transition to Downloading state.
12188        let stats = handle.stats().await.unwrap();
12189        assert_eq!(
12190            stats.state,
12191            TorrentState::Downloading,
12192            "should have transitioned to Downloading after pre-resolved metadata"
12193        );
12194        assert!(
12195            stats.pieces_total > 0,
12196            "should know piece count after metadata resolution"
12197        );
12198
12199        handle.shutdown().await.unwrap();
12200    }
12201
12202    #[tokio::test]
12203    async fn pre_resolved_metadata_ignored_after_resolution() {
12204        // Create a .torrent-based handle (already in Downloading state).
12205        let data = vec![0xAB; 32768];
12206        let meta = make_test_torrent(&data, 16384);
12207        let info_hash = meta.info_hash;
12208        let storage = make_storage(&data, 16384);
12209        let config = test_config();
12210
12211        let (atx, amask) = test_alert_channel();
12212        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12213        let handle = TorrentHandle::from_torrent(
12214            meta,
12215            irontide_core::TorrentVersion::V1Only,
12216            None,
12217            dh,
12218            dm,
12219            config,
12220            test_dht_rx(),
12221            test_dht_rx(),
12222            None,
12223            None,
12224            crate::slot_tuner::SlotTuner::disabled(4),
12225            atx,
12226            amask,
12227            None,
12228            None,
12229            test_ban_manager(),
12230            test_ip_filter(),
12231            Arc::new(Vec::new()),
12232            None,
12233            None,
12234            Arc::new(crate::transport::NetworkFactory::tokio()),
12235            None,
12236            Arc::new(crate::stats::SessionCounters::new()),
12237        )
12238        .await
12239        .unwrap();
12240
12241        let stats_before = handle.stats().await.unwrap();
12242        assert_eq!(stats_before.state, TorrentState::Downloading);
12243
12244        // Send pre-resolved metadata — should be silently ignored since
12245        // the actor is already past FetchingMetadata.
12246        let (info_bytes, _) = make_test_info_bytes();
12247        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12248
12249        // Give the actor time to process (or ignore) the command.
12250        tokio::time::sleep(Duration::from_millis(100)).await;
12251
12252        // Verify state hasn't changed and no crash occurred.
12253        let stats_after = handle.stats().await.unwrap();
12254        assert_eq!(stats_after.state, TorrentState::Downloading);
12255        assert_eq!(stats_after.pieces_total, stats_before.pieces_total);
12256
12257        handle.shutdown().await.unwrap();
12258    }
12259
12260    #[tokio::test]
12261    async fn pre_resolved_metadata_with_invalid_hash_stays_fetching() {
12262        // Build info bytes with a WRONG info_hash — the SHA1 won't match
12263        // the magnet link's info_hash, so try_assemble_metadata should
12264        // fail verification and the actor should stay in FetchingMetadata.
12265        let (info_bytes, _correct_hash) = make_test_info_bytes();
12266
12267        // Use a different (wrong) info_hash for the magnet.
12268        let wrong_hash = Id20::from_hex("0000000000000000000000000000000000000001").unwrap();
12269        let handle = create_magnet_handle(wrong_hash).await;
12270
12271        let stats = handle.stats().await.unwrap();
12272        assert_eq!(stats.state, TorrentState::FetchingMetadata);
12273
12274        // Send metadata with mismatched hash — should fail verification.
12275        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12276
12277        tokio::time::sleep(Duration::from_millis(200)).await;
12278
12279        // Actor should remain in FetchingMetadata (verification failed).
12280        let stats = handle.stats().await.unwrap();
12281        assert_eq!(
12282            stats.state,
12283            TorrentState::FetchingMetadata,
12284            "should stay in FetchingMetadata when info_hash doesn't match"
12285        );
12286
12287        handle.shutdown().await.unwrap();
12288    }
12289
12290    #[test]
12291    fn initial_queue_depth_is_128() {
12292        use crate::peer_shared::INITIAL_QUEUE_DEPTH;
12293        assert_eq!(INITIAL_QUEUE_DEPTH, 128);
12294    }
12295
12296    // ---- M159: seed mode scheduling-suppression integration test ----
12297
12298    /// End-to-end test that seed mode actually suppresses new block request
12299    /// dispatch at the wire level.
12300    ///
12301    /// 1. Spin up a 2-piece torrent with no downloaded data.
12302    /// 2. Connect a mock seeder that advertises both pieces.
12303    /// 3. Wait for the actor to send at least one `Request` (normal dispatch).
12304    /// 4. Flip `set_seed_mode(true)`.
12305    /// 5. Observe that a `Cancel` is sent for the pending request, and that
12306    ///    no additional `Request` messages arrive within 500 ms.
12307    /// 6. Confirm the stats snapshot reflects `user_seed_mode == true`.
12308    #[tokio::test]
12309    #[allow(
12310        clippy::large_stack_arrays,
12311        reason = "test data buffer passed directly to make_storage"
12312    )]
12313    async fn m159_seed_mode_suppresses_new_requests_on_wire() {
12314        let data = vec![0xAB; 32768]; // 32 KiB
12315        let meta = make_test_torrent(&data, 16384); // 2 pieces
12316        let info_hash = meta.info_hash;
12317        // Leecher has empty storage — wants both pieces.
12318        let storage = make_storage(&[0u8; 32768], 16384);
12319
12320        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12321        let listen_addr = listener.local_addr().unwrap();
12322        let config = TorrentConfig {
12323            listen_port: listen_addr.port(),
12324            ..test_config()
12325        };
12326        drop(listener);
12327
12328        let (atx, amask) = test_alert_channel();
12329        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12330        let handle = TorrentHandle::from_torrent(
12331            meta,
12332            irontide_core::TorrentVersion::V1Only,
12333            None,
12334            dh,
12335            dm,
12336            config,
12337            test_dht_rx(),
12338            test_dht_rx(),
12339            None,
12340            None,
12341            crate::slot_tuner::SlotTuner::disabled(4),
12342            atx,
12343            amask,
12344            None,
12345            None,
12346            test_ban_manager(),
12347            test_ip_filter(),
12348            Arc::new(Vec::new()),
12349            None,
12350            None,
12351            Arc::new(crate::transport::NetworkFactory::tokio()),
12352            None,
12353            Arc::new(crate::stats::SessionCounters::new()),
12354        )
12355        .await
12356        .unwrap();
12357
12358        tokio::time::sleep(Duration::from_millis(50)).await;
12359
12360        // Connect a mock seeder to the actor's listener.
12361        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12362        let (reader, writer) = tokio::io::split(stream);
12363        let mut writer = writer;
12364        let mut reader = reader;
12365
12366        let hs = Handshake::new(
12367            info_hash,
12368            Id20::from_hex("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
12369        );
12370        writer.write_all(&hs.to_bytes()).await.unwrap();
12371        writer.flush().await.unwrap();
12372        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12373        reader.read_exact(&mut hs_buf).await.unwrap();
12374
12375        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12376        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12377
12378        // Drain the actor's ext handshake, then send ours.
12379        let _actor_ext_hs = framed_read.next().await;
12380        let ext_hs = ExtHandshake::new();
12381        let ext_payload = ext_hs.to_bytes().unwrap();
12382        framed_write
12383            .send(Message::Extended {
12384                ext_id: 0,
12385                payload: ext_payload,
12386            })
12387            .await
12388            .unwrap();
12389
12390        // Announce that we (the mock seeder) have both pieces.
12391        let mut bf = Bitfield::new(2);
12392        bf.set(0);
12393        bf.set(1);
12394        framed_write
12395            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12396            .await
12397            .unwrap();
12398        framed_write.send(Message::Unchoke).await.unwrap();
12399
12400        // Wait for the actor to send its first Request (and any adjacent ones
12401        // inside one select tick). This confirms the normal dispatch path is
12402        // engaged before we flip into seed mode.
12403        let mut initial_request_seen = false;
12404        let wait_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12405        loop {
12406            let remaining = wait_deadline.saturating_duration_since(tokio::time::Instant::now());
12407            if remaining.is_zero() {
12408                break;
12409            }
12410            match tokio::time::timeout(remaining, framed_read.next()).await {
12411                Ok(Some(Ok(Message::Request { .. }))) => {
12412                    initial_request_seen = true;
12413                    break;
12414                }
12415                Ok(Some(Ok(_))) => {}
12416                _ => break,
12417            }
12418        }
12419        assert!(
12420            initial_request_seen,
12421            "actor should have sent a Request before seed mode toggle"
12422        );
12423
12424        // Flip user seed mode on. From this point forward the actor must not
12425        // dispatch any new Request messages.
12426        handle.set_seed_mode(true).await.unwrap();
12427
12428        // There's an inherent race between the actor processing the toggle
12429        // and the per-peer requester loop receiving its `DispatchCommand::Stop`
12430        // — a block may already be in the writer's queue when we flip. Drain
12431        // for a brief grace window, then verify the dispatch has fully halted
12432        // for a second longer window: if scheduling is truly suppressed, no
12433        // Request messages will arrive during the steady-state window.
12434        let grace_deadline = tokio::time::Instant::now() + Duration::from_millis(200);
12435        let mut cancel_seen = false;
12436        let mut grace_requests = 0u32;
12437        loop {
12438            let remaining = grace_deadline.saturating_duration_since(tokio::time::Instant::now());
12439            if remaining.is_zero() {
12440                break;
12441            }
12442            match tokio::time::timeout(remaining, framed_read.next()).await {
12443                Ok(Some(Ok(Message::Request { .. }))) => {
12444                    grace_requests += 1;
12445                }
12446                Ok(Some(Ok(Message::Cancel { .. }))) => {
12447                    cancel_seen = true;
12448                }
12449                Ok(Some(Ok(_))) => {}
12450                Ok(None | Some(Err(_))) | Err(_) => break,
12451            }
12452        }
12453        let _ = (cancel_seen, grace_requests);
12454
12455        // Steady-state window: if the dispatch path is really gated, zero
12456        // new Request messages must arrive for the next 500 ms.
12457        let steady_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
12458        let mut steady_requests = 0u32;
12459        loop {
12460            let remaining = steady_deadline.saturating_duration_since(tokio::time::Instant::now());
12461            if remaining.is_zero() {
12462                break;
12463            }
12464            match tokio::time::timeout(remaining, framed_read.next()).await {
12465                Ok(Some(Ok(Message::Request { .. }))) => {
12466                    steady_requests += 1;
12467                }
12468                Ok(Some(Ok(_))) => {}
12469                Ok(None | Some(Err(_))) | Err(_) => break,
12470            }
12471        }
12472
12473        assert_eq!(
12474            steady_requests, 0,
12475            "after the Stop propagation grace window, no new Request messages \
12476             must appear during steady-state while user_seed_mode is active"
12477        );
12478
12479        // Stats should reflect the flag.
12480        let stats = handle.stats().await.unwrap();
12481        assert!(
12482            stats.user_seed_mode,
12483            "stats.user_seed_mode should be true after set_seed_mode(true)"
12484        );
12485
12486        handle.shutdown().await.unwrap();
12487    }
12488
12489    // ---- M159 Task 1: Wire-level test — uploads continue in seed mode ----
12490    //
12491    // The point of user seed mode is to stop *downloading* (suppress new
12492    // block requests we issue to peers) while still *uploading* (honouring
12493    // incoming `Request` messages from peers who want pieces we have).
12494    // The companion test `m159_seed_mode_suppresses_new_requests_on_wire`
12495    // covers the download-suppression half; this one closes the loop by
12496    // asserting that the upload path survives a seed-mode toggle.
12497    //
12498    // Test shape:
12499    //   1. Pre-seed storage with two verified pieces (actor starts in
12500    //      `Seeding` state because `make_seeded_storage` writes the full
12501    //      dataset before the actor runs initial verification).
12502    //   2. Flip `user_seed_mode` on via `set_seed_mode(true)`. This is the
12503    //      load-bearing step — uploads must still work *after* seed mode
12504    //      is enabled.
12505    //   3. Connect a fake leecher via a real `TcpListener`, complete the
12506    //      BT + extended handshake.
12507    //   4. Announce an empty bitfield and send `Interested`. The choker
12508    //      still runs in seed mode, so the actor must respond with
12509    //      `Unchoke` (seed-mode choking algorithms unchoke interested
12510    //      peers based on upload throughput — a brand-new peer that just
12511    //      sent Interested is a valid candidate).
12512    //   5. Send `Request { index: 0, begin: 0, length: 16384 }` and assert
12513    //      a matching `Piece` message arrives on the wire within 2s, with
12514    //      a payload of the correct length and filled with the pre-seeded
12515    //      byte pattern.
12516    #[tokio::test]
12517    async fn m159_seed_mode_uploads_continue_on_wire() {
12518        const FILL_BYTE: u8 = 0x5A;
12519        const PIECE_LENGTH: u64 = 16384;
12520        const TOTAL_LEN: usize = 32768; // 2 pieces
12521
12522        let data = vec![FILL_BYTE; TOTAL_LEN];
12523        let meta = make_test_torrent(&data, PIECE_LENGTH);
12524        let info_hash = meta.info_hash;
12525        // Pre-seeded storage — actor transitions to Seeding after verify.
12526        let storage = make_seeded_storage(&data, PIECE_LENGTH);
12527
12528        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12529        let listen_addr = listener.local_addr().unwrap();
12530        let config = TorrentConfig {
12531            listen_port: listen_addr.port(),
12532            ..test_config()
12533        };
12534        drop(listener);
12535
12536        let (atx, amask) = test_alert_channel();
12537        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12538        let handle = TorrentHandle::from_torrent(
12539            meta,
12540            irontide_core::TorrentVersion::V1Only,
12541            None,
12542            dh,
12543            dm,
12544            config,
12545            test_dht_rx(),
12546            test_dht_rx(),
12547            None,
12548            None,
12549            crate::slot_tuner::SlotTuner::disabled(4),
12550            atx,
12551            amask,
12552            None,
12553            None,
12554            test_ban_manager(),
12555            test_ip_filter(),
12556            Arc::new(Vec::new()),
12557            None,
12558            None,
12559            Arc::new(crate::transport::NetworkFactory::tokio()),
12560            None,
12561            Arc::new(crate::stats::SessionCounters::new()),
12562        )
12563        .await
12564        .unwrap();
12565
12566        // Wait for initial verification to complete so the actor is really
12567        // in Seeding state before we flip seed mode. Poll stats up to 3s.
12568        let seeding_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12569        loop {
12570            tokio::time::sleep(Duration::from_millis(50)).await;
12571            let stats = handle.stats().await.unwrap();
12572            if stats.state == TorrentState::Seeding && stats.pieces_have == 2 {
12573                break;
12574            }
12575            if tokio::time::Instant::now() > seeding_deadline {
12576                let stats = handle.stats().await.unwrap();
12577                panic!(
12578                    "actor did not reach Seeding state within 3s: state={:?}, have={}/{}",
12579                    stats.state, stats.pieces_have, stats.pieces_total
12580                );
12581            }
12582        }
12583
12584        // Flip user seed mode on. The upload path must continue to serve
12585        // incoming Request messages from this point forward.
12586        handle.set_seed_mode(true).await.unwrap();
12587        let stats = handle.stats().await.unwrap();
12588        assert!(
12589            stats.user_seed_mode,
12590            "stats.user_seed_mode should be true after set_seed_mode(true)"
12591        );
12592
12593        // Connect a mock leecher to the actor's listener.
12594        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12595        let (reader, writer) = tokio::io::split(stream);
12596        let mut writer = writer;
12597        let mut reader = reader;
12598
12599        let hs = Handshake::new(
12600            info_hash,
12601            Id20::from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap(),
12602        );
12603        writer.write_all(&hs.to_bytes()).await.unwrap();
12604        writer.flush().await.unwrap();
12605        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12606        reader.read_exact(&mut hs_buf).await.unwrap();
12607
12608        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12609        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12610
12611        // Drain the actor's ext handshake, then send ours.
12612        let _actor_ext_hs = framed_read.next().await;
12613        let ext_hs = ExtHandshake::new();
12614        let ext_payload = ext_hs.to_bytes().unwrap();
12615        framed_write
12616            .send(Message::Extended {
12617                ext_id: 0,
12618                payload: ext_payload,
12619            })
12620            .await
12621            .unwrap();
12622
12623        // Tell the actor we (the mock leecher) have nothing.
12624        let bf = Bitfield::new(2);
12625        framed_write
12626            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12627            .await
12628            .unwrap();
12629        framed_write.send(Message::Interested).await.unwrap();
12630
12631        // Wait for Unchoke from the actor. The actor may also send its own
12632        // Bitfield/Have/Extended/Choke/etc.; we drain non-Unchoke messages
12633        // until we see it (or time out).
12634        let unchoke_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12635        let mut saw_unchoke = false;
12636        loop {
12637            let remaining = unchoke_deadline.saturating_duration_since(tokio::time::Instant::now());
12638            if remaining.is_zero() {
12639                break;
12640            }
12641            match tokio::time::timeout(remaining, framed_read.next()).await {
12642                Ok(Some(Ok(Message::Unchoke))) => {
12643                    saw_unchoke = true;
12644                    break;
12645                }
12646                Ok(Some(Ok(_))) => {}
12647                Ok(None | Some(Err(_))) => break,
12648                Err(_elapsed) => break,
12649            }
12650        }
12651        assert!(
12652            saw_unchoke,
12653            "actor should have unchoked the leecher while user_seed_mode is active"
12654        );
12655
12656        // Request piece 0, full 16 KiB block. The actor is seeding with
12657        // seed mode on — it must still serve this upload.
12658        framed_write
12659            .send(Message::Request {
12660                index: 0,
12661                begin: 0,
12662                length: PIECE_LENGTH as u32,
12663            })
12664            .await
12665            .unwrap();
12666
12667        // Expect a Piece message to arrive on the wire with matching
12668        // index/begin and the correct payload. Drain any other messages
12669        // (Have, Bitfield updates, Choke refreshes, etc.) that may arrive
12670        // first.
12671        let piece_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
12672        let mut got_piece = false;
12673        loop {
12674            let remaining = piece_deadline.saturating_duration_since(tokio::time::Instant::now());
12675            if remaining.is_zero() {
12676                break;
12677            }
12678            match tokio::time::timeout(remaining, framed_read.next()).await {
12679                Ok(Some(Ok(Message::Piece {
12680                    index,
12681                    begin,
12682                    data_0,
12683                    data_1,
12684                }))) => {
12685                    assert_eq!(index, 0, "Piece index should match request");
12686                    assert_eq!(begin, 0, "Piece begin should match request");
12687                    let mut payload: Vec<u8> =
12688                        Vec::with_capacity(data_0.len().saturating_add(data_1.len()));
12689                    payload.extend_from_slice(&data_0);
12690                    payload.extend_from_slice(&data_1);
12691                    assert_eq!(
12692                        payload.len(),
12693                        PIECE_LENGTH as usize,
12694                        "Piece payload length should match requested length"
12695                    );
12696                    assert!(
12697                        payload.iter().all(|&b| b == FILL_BYTE),
12698                        "Piece payload should contain the pre-seeded fill byte"
12699                    );
12700                    got_piece = true;
12701                    break;
12702                }
12703                Ok(Some(Ok(_))) => {}
12704                Ok(None | Some(Err(_))) => break,
12705                Err(_elapsed) => break,
12706            }
12707        }
12708        assert!(
12709            got_piece,
12710            "actor should have served a Piece in response to Request while user_seed_mode is active"
12711        );
12712
12713        // Stats should still reflect the seed-mode flag and accumulated
12714        // upload bytes for the one block we served.
12715        let stats = handle.stats().await.unwrap();
12716        assert!(
12717            stats.user_seed_mode,
12718            "stats.user_seed_mode should remain true after serving an upload"
12719        );
12720        assert!(
12721            stats.uploaded >= u64::from(PIECE_LENGTH as u32),
12722            "stats.uploaded should reflect the served block, got {}",
12723            stats.uploaded
12724        );
12725
12726        handle.shutdown().await.unwrap();
12727    }
12728
12729    // ---- M161: info dict, v2 hash, and timestamp tests ----
12730
12731    #[tokio::test]
12732    async fn info_field_populated_for_torrent() {
12733        let data = vec![0xAB; 32768];
12734        let meta = make_test_torrent(&data, 16384);
12735        let storage = make_storage(&data, 16384);
12736        let config = test_config();
12737
12738        let (atx, amask) = test_alert_channel();
12739        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12740        let handle = TorrentHandle::from_torrent(
12741            meta,
12742            irontide_core::TorrentVersion::V1Only,
12743            None,
12744            dh,
12745            dm,
12746            config,
12747            test_dht_rx(),
12748            test_dht_rx(),
12749            None,
12750            None,
12751            crate::slot_tuner::SlotTuner::disabled(4),
12752            atx,
12753            amask,
12754            None,
12755            None,
12756            test_ban_manager(),
12757            test_ip_filter(),
12758            Arc::new(Vec::new()),
12759            None,
12760            None,
12761            Arc::new(crate::transport::NetworkFactory::tokio()),
12762            None,
12763            Arc::new(crate::stats::SessionCounters::new()),
12764        )
12765        .await
12766        .unwrap();
12767
12768        tokio::time::sleep(Duration::from_millis(50)).await;
12769
12770        let rd = handle.save_resume_data().await.unwrap();
12771
12772        // info field must be populated when metadata is available
12773        assert!(rd.info.is_some(), "rd.info should be Some for .torrent");
12774
12775        // The embedded bytes must deserialize back to a valid InfoDict
12776        let info_bytes = rd.info.as_ref().unwrap();
12777        let info: irontide_core::InfoDict =
12778            irontide_bencode::from_bytes(info_bytes).expect("info bytes should deserialize");
12779        assert_eq!(info.name, "test");
12780        assert_eq!(info.piece_length, 16384);
12781
12782        handle.shutdown().await.unwrap();
12783    }
12784
12785    #[tokio::test]
12786    async fn info_hash2_none_for_v1_only() {
12787        let data = vec![0xCD; 16384];
12788        let meta = make_test_torrent(&data, 16384);
12789        let storage = make_storage(&data, 16384);
12790        let config = test_config();
12791
12792        let (atx, amask) = test_alert_channel();
12793        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12794        let handle = TorrentHandle::from_torrent(
12795            meta,
12796            irontide_core::TorrentVersion::V1Only,
12797            None,
12798            dh,
12799            dm,
12800            config,
12801            test_dht_rx(),
12802            test_dht_rx(),
12803            None,
12804            None,
12805            crate::slot_tuner::SlotTuner::disabled(4),
12806            atx,
12807            amask,
12808            None,
12809            None,
12810            test_ban_manager(),
12811            test_ip_filter(),
12812            Arc::new(Vec::new()),
12813            None,
12814            None,
12815            Arc::new(crate::transport::NetworkFactory::tokio()),
12816            None,
12817            Arc::new(crate::stats::SessionCounters::new()),
12818        )
12819        .await
12820        .unwrap();
12821
12822        tokio::time::sleep(Duration::from_millis(50)).await;
12823
12824        let rd = handle.save_resume_data().await.unwrap();
12825
12826        // v1-only torrent must not have a v2 hash
12827        assert!(
12828            rd.info_hash2.is_none(),
12829            "v1-only torrent should have info_hash2 = None"
12830        );
12831
12832        // Timestamps should be populated
12833        assert!(
12834            rd.added_time > 0,
12835            "added_time should be a positive POSIX timestamp"
12836        );
12837
12838        handle.shutdown().await.unwrap();
12839    }
12840
12841    #[tokio::test]
12842    async fn info_none_for_unresolved_magnet() {
12843        let magnet = Magnet {
12844            info_hashes: irontide_core::InfoHashes::v1_only(
12845                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
12846            ),
12847            display_name: Some("magnet-test".into()),
12848            trackers: vec![],
12849            peers: vec![],
12850            selected_files: None,
12851        };
12852        let config = test_config();
12853
12854        let (atx, amask) = test_alert_channel();
12855        let (dm, _dj) = test_disk_manager();
12856        let handle = TorrentHandle::from_magnet(
12857            magnet,
12858            dm,
12859            config,
12860            test_dht_rx(),
12861            test_dht_rx(),
12862            None,
12863            None,
12864            crate::slot_tuner::SlotTuner::disabled(4),
12865            atx,
12866            amask,
12867            None,
12868            None,
12869            test_ban_manager(),
12870            test_ip_filter(),
12871            Arc::new(Vec::new()),
12872            None,
12873            None,
12874            Arc::new(crate::transport::NetworkFactory::tokio()),
12875            None,
12876            Arc::new(crate::stats::SessionCounters::new()),
12877        )
12878        .await
12879        .unwrap();
12880
12881        tokio::time::sleep(Duration::from_millis(50)).await;
12882
12883        let rd = handle.save_resume_data().await.unwrap();
12884
12885        // Unresolved magnet has no metadata, so info must be None
12886        assert!(
12887            rd.info.is_none(),
12888            "unresolved magnet should have info = None"
12889        );
12890
12891        // added_time should still be set even for magnets
12892        assert!(
12893            rd.added_time > 0,
12894            "added_time should be set for magnet links"
12895        );
12896
12897        handle.shutdown().await.unwrap();
12898    }
12899
12900    // ---- v0.173.1: TorrentCommand::GetMeta tests (Class A architectural fix) ----
12901
12902    #[tokio::test]
12903    async fn torrent_command_get_meta_returns_none_before_metadata() {
12904        // v0.173.1: pre-metadata magnet handles must return None from GetMeta.
12905        let info_hash =
12906            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").expect("valid hex");
12907        let handle = create_magnet_handle(info_hash).await;
12908
12909        let (tx, rx) = oneshot::channel();
12910        handle
12911            .cmd_tx
12912            .send(TorrentCommand::GetMeta { reply: tx })
12913            .await
12914            .expect("cmd_tx send");
12915        let result = rx.await.expect("GetMeta reply");
12916        assert!(
12917            result.is_none(),
12918            "pre-metadata magnet must return None from GetMeta"
12919        );
12920
12921        handle.shutdown().await.unwrap();
12922    }
12923
12924    #[tokio::test]
12925    async fn torrent_command_get_meta_returns_some_after_metadata() {
12926        // v0.173.1: once metadata is assembled (via PreResolvedMetadata push),
12927        // GetMeta must return Some(meta) with the matching info hash.
12928        let (info_bytes, info_hash) = make_test_info_bytes();
12929        let handle = create_magnet_handle(info_hash).await;
12930
12931        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12932
12933        // Poll GetMeta until it returns Some or we exceed a 2s budget — the
12934        // PreResolvedMetadata command runs through the actor select! loop
12935        // asynchronously so we can't rely on a hard sleep.
12936        let mut result = None;
12937        for _ in 0..100 {
12938            tokio::time::sleep(Duration::from_millis(20)).await;
12939            let (tx, rx) = oneshot::channel();
12940            handle
12941                .cmd_tx
12942                .send(TorrentCommand::GetMeta { reply: tx })
12943                .await
12944                .expect("cmd_tx send");
12945            let r = rx.await.expect("GetMeta reply");
12946            if r.is_some() {
12947                result = r;
12948                break;
12949            }
12950        }
12951        let meta = result.expect("GetMeta must return Some after PreResolvedMetadata");
12952        assert_eq!(meta.info_hash, info_hash);
12953
12954        handle.shutdown().await.unwrap();
12955    }
12956
12957    // ── M178 Lane B1: WebSeedStats actor state machine ───────────────
12958
12959    #[tokio::test]
12960    async fn web_seed_progress_idle_to_active_on_first_success() {
12961        let mut actor = TorrentActor::for_throttle_test(8, 0);
12962        actor.handle_web_seed_progress("http://seed.example/file", 1024, 1_000_000, None);
12963        let stats = actor
12964            .web_seed_stats
12965            .get("http://seed.example/file")
12966            .expect("stats inserted");
12967        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12968        assert_eq!(stats.downloaded_bytes, 1024);
12969        assert_eq!(stats.last_rate_bps, 1_000_000);
12970        assert_eq!(stats.consecutive_failures, 0);
12971        assert!(stats.last_attempt_unix_secs > 0);
12972        assert!(actor.need_save_resume);
12973    }
12974
12975    #[tokio::test]
12976    async fn web_seed_progress_active_to_errored_then_recovery_persists_last_error() {
12977        let mut actor = TorrentActor::for_throttle_test(8, 0);
12978        let url = "http://seed.example/file".to_string();
12979
12980        // 1) Initial success → Active
12981        actor.handle_web_seed_progress(&url, 1024, 100, None);
12982        assert_eq!(
12983            actor.web_seed_stats[&url].state,
12984            irontide_core::WebSeedState::Active
12985        );
12986
12987        // 2) Failure → Errored, last_error populated
12988        actor.handle_web_seed_progress(&url, 1024, 0, Some("503".into()));
12989        let stats = &actor.web_seed_stats[&url];
12990        assert_eq!(stats.state, irontide_core::WebSeedState::Errored);
12991        assert_eq!(stats.last_error.as_deref(), Some("503"));
12992        assert_eq!(stats.consecutive_failures, 1);
12993
12994        // 3) Recovery → Active, but last_error PERSISTS (Issue 2.2)
12995        actor.handle_web_seed_progress(&url, 2048, 200, None);
12996        let stats = &actor.web_seed_stats[&url];
12997        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12998        assert_eq!(
12999            stats.last_error.as_deref(),
13000            Some("503"),
13001            "last_error must persist through recovery (D-eng-8)"
13002        );
13003        assert_eq!(
13004            stats.consecutive_failures, 0,
13005            "consecutive_failures resets on success"
13006        );
13007    }
13008
13009    #[tokio::test]
13010    async fn web_seed_progress_consecutive_failures_monotonic_within_run() {
13011        let mut actor = TorrentActor::for_throttle_test(8, 0);
13012        let url = "http://seed.example/file".to_string();
13013
13014        actor.handle_web_seed_progress(&url, 0, 0, Some("e1".into()));
13015        actor.handle_web_seed_progress(&url, 0, 0, Some("e2".into()));
13016        actor.handle_web_seed_progress(&url, 0, 0, Some("e3".into()));
13017        let stats = &actor.web_seed_stats[&url];
13018        assert_eq!(stats.consecutive_failures, 3);
13019        assert_eq!(
13020            stats.last_error.as_deref(),
13021            Some("e3"),
13022            "last_error reflects most recent message"
13023        );
13024
13025        actor.handle_web_seed_progress(&url, 1024, 100, None);
13026        assert_eq!(
13027            actor.web_seed_stats[&url].consecutive_failures, 0,
13028            "success resets consecutive_failures"
13029        );
13030    }
13031
13032    // ── M178 Lane B3: PeX + LSD peer counters ────────────────────────
13033
13034    /// Inject a `PeerStates` into a synthetic actor so `handle_add_peers`
13035    /// can run without spinning up the full peer pipeline.
13036    fn install_peer_states(actor: &mut TorrentActor) {
13037        let (queue_tx, _queue_rx) = mpsc::unbounded_channel();
13038        actor.peer_states = Some(std::sync::Arc::new(crate::peer_states::PeerStates::new(
13039            queue_tx,
13040        )));
13041    }
13042
13043    fn addr(octet: u8, port: u16) -> std::net::SocketAddr {
13044        std::net::SocketAddr::new(
13045            std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, octet)),
13046            port,
13047        )
13048    }
13049
13050    #[tokio::test]
13051    async fn pex_count_dedups_same_peer_in_two_messages() {
13052        let mut actor = TorrentActor::for_throttle_test(8, 0);
13053        install_peer_states(&mut actor);
13054
13055        // Message 1: peers A and B
13056        actor.handle_add_peers(
13057            vec![addr(1, 6881), addr(2, 6881)],
13058            crate::peer_state::PeerSource::Pex,
13059        );
13060        // Message 2: peer A again, plus C
13061        actor.handle_add_peers(
13062            vec![addr(1, 6881), addr(3, 6881)],
13063            crate::peer_state::PeerSource::Pex,
13064        );
13065        assert_eq!(
13066            actor.pex_peer_count, 3,
13067            "3 unique peers across 2 PEX messages, A counted once"
13068        );
13069        assert_eq!(actor.lsd_peer_count, 0, "LSD untouched");
13070    }
13071
13072    #[tokio::test]
13073    async fn lsd_count_aggregates_across_multicasts() {
13074        let mut actor = TorrentActor::for_throttle_test(8, 0);
13075        install_peer_states(&mut actor);
13076
13077        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Lsd);
13078        actor.handle_add_peers(
13079            vec![addr(2, 6881), addr(3, 6881)],
13080            crate::peer_state::PeerSource::Lsd,
13081        );
13082        actor.handle_add_peers(
13083            vec![addr(1, 6881)], // dup
13084            crate::peer_state::PeerSource::Lsd,
13085        );
13086        assert_eq!(actor.lsd_peer_count, 3);
13087    }
13088
13089    #[tokio::test]
13090    async fn other_sources_do_not_bump_pex_or_lsd() {
13091        let mut actor = TorrentActor::for_throttle_test(8, 0);
13092        install_peer_states(&mut actor);
13093
13094        actor.handle_add_peers(
13095            vec![addr(1, 6881), addr(2, 6881)],
13096            crate::peer_state::PeerSource::Tracker,
13097        );
13098        actor.handle_add_peers(vec![addr(3, 6881)], crate::peer_state::PeerSource::Dht);
13099        actor.handle_add_peers(vec![addr(4, 6881)], crate::peer_state::PeerSource::Incoming);
13100        assert_eq!(actor.pex_peer_count, 0);
13101        assert_eq!(actor.lsd_peer_count, 0);
13102    }
13103
13104    #[tokio::test]
13105    async fn dedup_runs_against_global_seen_set() {
13106        // A peer first observed via tracker won't recount when later
13107        // re-announced via PEX, because the seen-set is shared across
13108        // sources. This is the intended behaviour: PEX/LSD counts measure
13109        // *new* peer discoveries from those subsystems, not redundant
13110        // re-announcements.
13111        let mut actor = TorrentActor::for_throttle_test(8, 0);
13112        install_peer_states(&mut actor);
13113
13114        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Tracker);
13115        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Pex);
13116        assert_eq!(
13117            actor.pex_peer_count, 0,
13118            "peer already seen via tracker — PEX shouldn't re-count"
13119        );
13120    }
13121
13122    #[tokio::test]
13123    async fn web_seed_progress_dirties_resume_flag() {
13124        let mut actor = TorrentActor::for_throttle_test(8, 0);
13125        actor.need_save_resume = false;
13126        actor.handle_web_seed_progress("http://x/file", 100, 50, None);
13127        assert!(
13128            actor.need_save_resume,
13129            "every progress event should mark fast-resume dirty"
13130        );
13131    }
13132
13133    #[tokio::test]
13134    async fn paused_torrent_rejects_outbound_peer_connect() {
13135        let mut actor = TorrentActor::for_throttle_test(8, 0);
13136        install_peer_states(&mut actor);
13137        actor.state = TorrentState::Paused;
13138
13139        let sem = Arc::new(tokio::sync::Semaphore::new(1));
13140        let permit = sem.clone().acquire_owned().await.unwrap();
13141        let connect = crate::peer_adder::ConnectPeer {
13142            addr: addr(1, 6881),
13143            source: crate::peer_state::PeerSource::Dht,
13144            permit,
13145        };
13146        actor.handle_adder_connect(connect);
13147        assert!(
13148            actor.peers.is_empty(),
13149            "paused torrent must not accept outbound peer connections"
13150        );
13151        assert_eq!(
13152            sem.available_permits(),
13153            1,
13154            "semaphore permit must be released on rejection"
13155        );
13156    }
13157
13158    #[tokio::test]
13159    async fn resume_from_queued_restores_fetching_metadata_for_magnets() {
13160        let mut actor = TorrentActor::for_throttle_test(0, 0);
13161        actor.state = TorrentState::Queued;
13162        assert!(
13163            actor.chunk_tracker.is_none(),
13164            "magnet torrent has no chunk tracker before metadata"
13165        );
13166        assert_eq!(actor.num_pieces, 0);
13167
13168        actor.handle_resume().await;
13169        assert_eq!(
13170            actor.state,
13171            TorrentState::FetchingMetadata,
13172            "magnet torrent must resume to FetchingMetadata, not Downloading"
13173        );
13174    }
13175
13176    #[tokio::test]
13177    async fn resume_from_queued_restores_downloading_when_metadata_known() {
13178        let mut actor = TorrentActor::for_throttle_test(8, 0);
13179        actor.state = TorrentState::Queued;
13180
13181        actor.handle_resume().await;
13182        assert_eq!(
13183            actor.state,
13184            TorrentState::Downloading,
13185            "torrent with known pieces must resume to Downloading"
13186        );
13187    }
13188
13189    #[tokio::test]
13190    async fn queued_torrent_rejects_outbound_peer_connect() {
13191        let mut actor = TorrentActor::for_throttle_test(8, 0);
13192        install_peer_states(&mut actor);
13193        actor.state = TorrentState::Queued;
13194
13195        let sem = Arc::new(tokio::sync::Semaphore::new(1));
13196        let permit = sem.clone().acquire_owned().await.unwrap();
13197        let connect = crate::peer_adder::ConnectPeer {
13198            addr: addr(1, 6881),
13199            source: crate::peer_state::PeerSource::Dht,
13200            permit,
13201        };
13202        actor.handle_adder_connect(connect);
13203        assert!(
13204            actor.peers.is_empty(),
13205            "queued torrent must not accept outbound peer connections"
13206        );
13207        assert_eq!(
13208            sem.available_permits(),
13209            1,
13210            "semaphore permit must be released on rejection"
13211        );
13212    }
13213
13214    /// Inject a synthetic `PeerState` directly into `actor.peers` so
13215    /// `disconnect_peer` exercises the flush path without spinning up
13216    /// real peer tasks.
13217    fn inject_peer_for_flush(
13218        actor: &mut TorrentActor,
13219        peer_addr: std::net::SocketAddr,
13220        unchoke_started: Option<std::time::Instant>,
13221        prior_total: std::time::Duration,
13222    ) {
13223        let (cmd_tx, _cmd_rx) = mpsc::channel(8);
13224        let mut peer = crate::peer_state::PeerState::new(
13225            peer_addr,
13226            actor.num_pieces,
13227            cmd_tx,
13228            crate::peer_state::PeerSource::Tracker,
13229            Arc::new(AtomicU32::new(0)),
13230            Arc::new(AtomicU32::new(128)),
13231            Arc::new(tokio::sync::Notify::new()),
13232        );
13233        peer.am_unchoke_started_at = unchoke_started;
13234        peer.unchoke_duration_total = prior_total;
13235        actor.peers.insert(peer_addr, peer);
13236    }
13237
13238    #[tokio::test]
13239    async fn disconnect_while_unchoked_flushes_delta_into_torrent_map() {
13240        let mut actor = TorrentActor::for_throttle_test(8, 0);
13241        let p = addr(1, 6881);
13242
13243        // Seed the peer with an in-flight unchoke window opened ~50 ms ago
13244        // and a pre-existing 100 ms accumulator from prior toggles.
13245        inject_peer_for_flush(
13246            &mut actor,
13247            p,
13248            Some(std::time::Instant::now() - std::time::Duration::from_millis(50)),
13249            std::time::Duration::from_millis(100),
13250        );
13251
13252        actor.disconnect_peer(p, "test");
13253
13254        let total = actor
13255            .unchoke_durations
13256            .get(&p)
13257            .copied()
13258            .expect("disconnect must flush a non-zero delta into the torrent map");
13259        assert!(
13260            total >= std::time::Duration::from_millis(140),
13261            "expected ≥140 ms (100 prior + ~50 in-flight), got {total:?}"
13262        );
13263    }
13264
13265    #[tokio::test]
13266    async fn disconnect_then_reconnect_preserves_history() {
13267        let mut actor = TorrentActor::for_throttle_test(8, 0);
13268        let p = addr(2, 6881);
13269
13270        // First connection: 80 ms unchoke window already accumulated.
13271        inject_peer_for_flush(&mut actor, p, None, std::time::Duration::from_millis(80));
13272        actor.disconnect_peer(p, "test");
13273        let after_first = *actor
13274            .unchoke_durations
13275            .get(&p)
13276            .expect("first flush must populate the entry");
13277        assert_eq!(after_first, std::time::Duration::from_millis(80));
13278
13279        // Reconnect: peer rejoins with a fresh in-flight window.
13280        inject_peer_for_flush(
13281            &mut actor,
13282            p,
13283            Some(std::time::Instant::now() - std::time::Duration::from_millis(40)),
13284            std::time::Duration::ZERO,
13285        );
13286        actor.disconnect_peer(p, "test");
13287        let after_second = *actor.unchoke_durations.get(&p).unwrap();
13288        assert!(
13289            after_second >= std::time::Duration::from_millis(120),
13290            "second flush must add to the existing entry, got {after_second:?}"
13291        );
13292    }
13293
13294    // -- M187 Fix B: piece-verified wakes reservation_notify --
13295
13296    #[tokio::test]
13297    async fn piece_verified_wakes_reservation_notify() {
13298        let mut actor = TorrentActor::for_throttle_test(8, 0);
13299        let notify = Arc::new(tokio::sync::Notify::new());
13300        actor.reservation_notify = Some(Arc::clone(&notify));
13301
13302        let notified = notify.notified();
13303        tokio::pin!(notified);
13304        assert!(
13305            futures::poll!(&mut notified).is_pending(),
13306            "notify should not have fired yet"
13307        );
13308
13309        actor.on_piece_verified(0).await;
13310
13311        tokio::time::timeout(Duration::from_secs(1), notified)
13312            .await
13313            .expect("reservation_notify must be woken by on_piece_verified");
13314    }
13315
13316    // -- 2026-05-11 state-gated pipeline-tick safety-net wake --
13317
13318    /// Helper: construct an actor that already has a `PieceTracker` with the
13319    /// given (`queue_count`, `inflight_count`). The tracker starts empty and
13320    /// we mark pieces wanted/reserved as needed to land on the target shape.
13321    fn actor_with_tracker_state(queue: u32, inflight: u32) -> TorrentActor {
13322        use crate::piece_reservation::PieceTracker;
13323        use irontide_storage::Bitfield;
13324        let mut actor = TorrentActor::for_throttle_test(8, 0);
13325        let num_pieces = queue + inflight + 1;
13326        let we_have = Bitfield::new(num_pieces);
13327        let mut wanted = Bitfield::new(num_pieces);
13328        for i in 0..num_pieces {
13329            wanted.set(i);
13330        }
13331        let mut pt = PieceTracker::new(num_pieces, &we_have, &wanted);
13332        // Trim the queue down to `queue` (the rest become "completed" by
13333        // marking them unwanted, which clears them from queue_pieces).
13334        for i in queue..num_pieces {
13335            pt.mark_unwanted(i);
13336        }
13337        // Move `inflight` pieces from queue to inflight via record_reservation.
13338        for i in 0..inflight {
13339            pt.record_reservation(i, "10.0.0.1:6881".parse().unwrap());
13340        }
13341        // After this: queue_count() == queue - inflight, inflight_count() == inflight.
13342        // We started with `queue` wanted pieces, then reserved `inflight` of
13343        // them, leaving (queue - inflight) in the queue. Adjust caller-facing
13344        // semantics so the helper's name matches the assertion.
13345        actor.piece_tracker = Some(pt);
13346        actor
13347    }
13348
13349    #[tokio::test]
13350    async fn pipeline_tick_skips_wake_when_dispatch_state_unchanged() {
13351        let mut actor = actor_with_tracker_state(10, 3);
13352        let notify = Arc::new(tokio::sync::Notify::new());
13353        actor.reservation_notify = Some(Arc::clone(&notify));
13354
13355        // First tick seeds the baseline and always wakes — this matches the
13356        // helper's documented first-call semantics. Drop the baseline wake
13357        // by polling once before installing the real test waiter.
13358        actor.tick_dispatch_safety_wake();
13359        let _drain = notify.notified();
13360
13361        // No dispatch state change between this tick and the next.
13362        let notified = notify.notified();
13363        tokio::pin!(notified);
13364        actor.tick_dispatch_safety_wake();
13365
13366        // Give tokio a chance to dispatch any pending wakes before asserting.
13367        tokio::task::yield_now().await;
13368        assert!(
13369            futures::poll!(&mut notified).is_pending(),
13370            "tick must not wake when (queue_count, inflight_count) is unchanged"
13371        );
13372        // And the skip counter increments.
13373        let skipped = actor.counters.get(crate::stats::DISPATCH_TICK_WAKE_SKIPPED);
13374        assert!(
13375            skipped >= 1,
13376            "expected DISPATCH_TICK_WAKE_SKIPPED >= 1, got {skipped}"
13377        );
13378    }
13379
13380    #[tokio::test]
13381    async fn pipeline_tick_wakes_when_inflight_changes() {
13382        let mut actor = actor_with_tracker_state(10, 3);
13383        let notify = Arc::new(tokio::sync::Notify::new());
13384        actor.reservation_notify = Some(Arc::clone(&notify));
13385
13386        // Seed baseline.
13387        actor.tick_dispatch_safety_wake();
13388
13389        // Mutate dispatch state: reserve another piece via the tracker. This
13390        // changes both queue_count (down 1) and inflight_count (up 1).
13391        if let Some(ref mut pt) = actor.piece_tracker {
13392            pt.record_reservation(5, "10.0.0.2:6881".parse().unwrap());
13393        }
13394
13395        let notified = notify.notified();
13396        tokio::pin!(notified);
13397        actor.tick_dispatch_safety_wake();
13398
13399        tokio::time::timeout(Duration::from_secs(1), notified)
13400            .await
13401            .expect("tick must wake when dispatch state changed");
13402    }
13403
13404    /// M257c: inject a synthetic unchoked peer with a seeded EWMA rate so
13405    /// `apply_request_budget` has real allocator inputs to chew on.
13406    fn inject_budget_peer(
13407        actor: &mut TorrentActor,
13408        peer_addr: std::net::SocketAddr,
13409        bytes_last_sec: u32,
13410    ) {
13411        let (cmd_tx, _cmd_rx) = mpsc::channel(8);
13412        let mut peer = crate::peer_state::PeerState::new(
13413            peer_addr,
13414            actor.num_pieces,
13415            cmd_tx,
13416            crate::peer_state::PeerSource::Tracker,
13417            Arc::new(AtomicU32::new(0)),
13418            Arc::new(AtomicU32::new(128)),
13419            Arc::new(tokio::sync::Notify::new()),
13420        );
13421        peer.peer_choking = false;
13422        peer.pipeline.block_received(bytes_last_sec);
13423        peer.pipeline.tick(); // ewma = 0.3 × bytes_last_sec
13424        actor.peers.insert(peer_addr, peer);
13425    }
13426
13427    #[tokio::test]
13428    async fn m257c_pipeline_tick_reallocates_target_depth_by_rate() {
13429        use std::sync::atomic::Ordering::Relaxed;
13430
13431        let mut actor = TorrentActor::for_throttle_test(8, 0);
13432        // for_throttle_test builds diagnostics-off counters; the realloc
13433        // counter is diagnostic-gated, so swap in an enabled set.
13434        actor.counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(true));
13435        actor.config.request_budget_per_torrent = 144;
13436        actor.config.request_budget_floor = 8;
13437
13438        let fast = addr(1, 6881);
13439        let slow = addr(2, 6881);
13440        // 3:1 rate skew (ewma ≈ 9000 vs ≈ 3000 B/s after one tick).
13441        inject_budget_peer(&mut actor, fast, 30_000);
13442        inject_budget_peer(&mut actor, slow, 10_000);
13443
13444        actor.apply_request_budget();
13445
13446        let fast_q = actor.peers.get(&fast).unwrap().target_depth.load(Relaxed);
13447        let slow_q = actor.peers.get(&slow).unwrap().target_depth.load(Relaxed);
13448        assert!(
13449            fast_q > slow_q,
13450            "faster peer must get the larger quota (fast {fast_q} vs slow {slow_q})"
13451        );
13452        assert!(
13453            fast_q + slow_q <= 144,
13454            "quota sum {} must respect the budget 144",
13455            fast_q + slow_q
13456        );
13457        assert!(slow_q >= 8, "slow peer must hold the floor, got {slow_q}");
13458        assert!(
13459            fast_q <= crate::peer_shared::INITIAL_QUEUE_DEPTH as u32,
13460            "per-peer quota must stay capped, got {fast_q}"
13461        );
13462        let reallocs = actor.counters.get(crate::stats::BUDGET_REALLOCS_TOTAL);
13463        assert!(
13464            reallocs >= 1,
13465            "expected BUDGET_REALLOCS_TOTAL >= 1, got {reallocs}"
13466        );
13467
13468        // Live-disable: budget 0 ⇒ next allocation restores the legacy
13469        // fixed depth on every peer (the tick calls this unconditionally).
13470        actor.config.request_budget_per_torrent = 0;
13471        actor.apply_request_budget();
13472        let fast_q = actor.peers.get(&fast).unwrap().target_depth.load(Relaxed);
13473        let slow_q = actor.peers.get(&slow).unwrap().target_depth.load(Relaxed);
13474        assert_eq!(
13475            (fast_q, slow_q),
13476            (
13477                crate::peer_shared::INITIAL_QUEUE_DEPTH as u32,
13478                crate::peer_shared::INITIAL_QUEUE_DEPTH as u32
13479            ),
13480            "disabling the budget must restore legacy depth"
13481        );
13482    }
13483
13484    #[tokio::test]
13485    async fn m257f_bdp_cap_binds_target_depth_by_rtt() {
13486        use std::sync::atomic::Ordering::Relaxed;
13487
13488        // Two peers, one with a measured 160 ms RTT and one cold (no
13489        // RTT sample): after one tick the measured peer's target is
13490        // formula-bound while the cold peer stays at the permissive 128.
13491        let mut actor = TorrentActor::for_throttle_test(8, 0);
13492        actor.counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(true));
13493        actor.config.request_budget_per_torrent = 1024; // ample: caps bind, not budget
13494        actor.config.request_budget_floor = 8;
13495
13496        let fast = addr(1, 6881);
13497        let cold = addr(2, 6881);
13498        inject_budget_peer(&mut actor, fast, 30_000);
13499        inject_budget_peer(&mut actor, cold, 30_000);
13500        {
13501            let p = actor.peers.get_mut(&fast).unwrap();
13502            p.pipeline.set_ewma_for_test(16_000_000.0);
13503            p.pipeline.set_window_for_test(16_000_000); // cap input (raw)
13504            p.avg_rtt = Some(0.160);
13505        }
13506        {
13507            let p = actor.peers.get_mut(&cold).unwrap();
13508            p.pipeline.set_ewma_for_test(16_000_000.0);
13509            p.pipeline.set_window_for_test(16_000_000);
13510            p.avg_rtt = None; // never delivered a tracked block
13511        }
13512
13513        actor.apply_request_budget();
13514
13515        // 16 MB/s × 0.160 s / 16 KiB = 156.25 → ceil 157 + slack 4 = 161.
13516        let fast_q = actor.peers.get(&fast).unwrap().target_depth.load(Relaxed);
13517        let cold_q = actor.peers.get(&cold).unwrap().target_depth.load(Relaxed);
13518        assert_eq!(fast_q, 161, "measured peer binds at its BDP cap");
13519        assert_eq!(cold_q, 128, "cold peer stays at legacy init");
13520
13521        // Shrink hysteresis: drop the fast peer's measured rate to
13522        // 2 MB/s (computed cap 24 < 161). Ticks 1-2 hold; tick 3 lands.
13523        {
13524            let p = actor.peers.get_mut(&fast).unwrap();
13525            p.pipeline.set_ewma_for_test(2_000_000.0);
13526            p.pipeline.set_window_for_test(2_000_000);
13527        }
13528        actor.apply_request_budget();
13529        assert_eq!(
13530            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13531            161,
13532            "first shrink signal holds the prior cap"
13533        );
13534        actor.apply_request_budget();
13535        assert_eq!(
13536            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13537            161,
13538            "second shrink signal still holds"
13539        );
13540        actor.apply_request_budget();
13541        // 2 MB/s × 0.160 s / 16 KiB = 19.5 → ceil 20 + slack 4 = 24.
13542        assert_eq!(
13543            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13544            24,
13545            "third consecutive shrink signal lands"
13546        );
13547
13548        // Choke gate (OV round-1 F1): choke the fast peer and decay its
13549        // measured rate to 0 — three ticks must NOT shrink the warm cap
13550        // (the formula is skipped while choked; the allocator pins the
13551        // quota at the probe floor, the cap field stays 24).
13552        {
13553            let p = actor.peers.get_mut(&fast).unwrap();
13554            p.peer_choking = true;
13555            p.pipeline.set_ewma_for_test(0.0);
13556            p.pipeline.set_window_for_test(0);
13557        }
13558        actor.apply_request_budget();
13559        actor.apply_request_budget();
13560        actor.apply_request_budget();
13561        assert_eq!(
13562            actor.peers.get(&fast).unwrap().bdp_cap,
13563            24,
13564            "choked peer's cap holds warm — no decay-driven shrink"
13565        );
13566        assert_eq!(
13567            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13568            8,
13569            "choked peer pinned at the probe floor while choked"
13570        );
13571
13572        // Unchoke: the warm cap lets the spill pass restore quota 24 on
13573        // the FIRST tick — no slow re-ramp from floor (the F1 rescue;
13574        // rate is still decayed to 0, so floor+share is 8 and the
13575        // remaining 16 arrives via cap spill).
13576        actor.peers.get_mut(&fast).unwrap().peer_choking = false;
13577        actor.apply_request_budget();
13578        assert_eq!(
13579            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13580            24,
13581            "spill restores the warm cap's full quota immediately"
13582        );
13583
13584        // Live disable restores legacy for everyone within one tick.
13585        actor.config.request_budget_per_torrent = 0;
13586        actor.apply_request_budget();
13587        assert_eq!(
13588            actor.peers.get(&fast).unwrap().target_depth.load(Relaxed),
13589            crate::request_budget::LEGACY_DEPTH
13590        );
13591        assert_eq!(
13592            actor.peers.get(&cold).unwrap().target_depth.load(Relaxed),
13593            crate::request_budget::LEGACY_DEPTH
13594        );
13595    }
13596
13597    #[tokio::test]
13598    async fn m257f_churn_suspends_bdp_pricing() {
13599        // Rotation-shared swarms have sub-block honest BDP (contended
13600        // evidence: bdp_blocks_estimate 0.92 at 156 flips/min) — depth
13601        // there is stall-absorption (the M257d/M257e axis). Above the
13602        // churn threshold, pricing suspends and caps pin LEGACY (the
13603        // M257c regime); recovery is immediate when churn subsides.
13604        let mut actor = TorrentActor::for_throttle_test(8, 0);
13605        actor.config.request_budget_per_torrent = 1024;
13606        actor.config.request_budget_floor = 8;
13607        let a = addr(1, 6881);
13608        inject_budget_peer(&mut actor, a, 30_000);
13609        {
13610            let p = actor.peers.get_mut(&a).unwrap();
13611            p.pipeline.set_window_for_test(2_000_000);
13612            p.avg_rtt = Some(0.030);
13613        }
13614
13615        // Stable swarm (zero remote choke edges): pricing is active —
13616        // 2 MB/s × 30 ms = 3.66 → ceil 4 + 4 = 8; three confirms land.
13617        actor.apply_request_budget();
13618        actor.apply_request_budget();
13619        actor.apply_request_budget();
13620        assert_eq!(actor.peers.get(&a).unwrap().bdp_cap, 8);
13621        assert!(
13622            actor.rechoke_per_min_est.abs() < f64::EPSILON,
13623            "initial unchoke is a false-edge — never counted as churn"
13624        );
13625
13626        // Churn detected: caps pin LEGACY on the next tick (assignment,
13627        // not skip — recovery from a landed shrink is immediate).
13628        actor.rechoke_per_min_est = 50.0;
13629        actor.apply_request_budget();
13630        assert_eq!(
13631            actor.peers.get(&a).unwrap().bdp_cap,
13632            crate::request_budget::LEGACY_DEPTH,
13633            "churn suspension runs the permissive M257c regime"
13634        );
13635
13636        // Churn subsides: pricing re-engages, the shrink staircase
13637        // restarts from LEGACY and re-lands in three confirms.
13638        actor.rechoke_per_min_est = 0.0;
13639        actor.apply_request_budget();
13640        actor.apply_request_budget();
13641        actor.apply_request_budget();
13642        assert_eq!(actor.peers.get(&a).unwrap().bdp_cap, 8);
13643
13644        // The edge detector feeds the estimate: a remote choke flip is
13645        // one choke-direction edge → 0.1 × 1 × 60 = 6.0/min (a single
13646        // rechoke does NOT trip the 30.0 threshold).
13647        actor.peers.get_mut(&a).unwrap().peer_choking = true;
13648        actor.apply_request_budget();
13649        assert!(
13650            (actor.rechoke_per_min_est - 6.0).abs() < 1e-9,
13651            "one choke edge folds into the EWMA: {}",
13652            actor.rechoke_per_min_est
13653        );
13654
13655        // M257f run-4 calibration pin: a dedicated high-BDP link's normal
13656        // optimistic-unchoke cycling reads ~12.25 onsets/min — it must stay
13657        // BDP-priced. The earlier 12.0 threshold sat inside that band and
13658        // thrashed the cap between suspend (pin 128) and price (grow → 156),
13659        // degrading high_bdp to in_flight 146.6 / bytes 0.853; 30.0 clears
13660        // the band. At 12.25 the cap must stay priced (< LEGACY), not pin.
13661        actor.peers.get_mut(&a).unwrap().peer_choking = false;
13662        actor.peers.get_mut(&a).unwrap().prev_choking = false;
13663        actor.rechoke_per_min_est = 12.25;
13664        actor.apply_request_budget();
13665        assert!(
13666            actor.peers.get(&a).unwrap().bdp_cap < crate::request_budget::LEGACY_DEPTH,
13667            "high_bdp's ~12.25/min cycling must stay BDP-priced, not pin LEGACY (cap={})",
13668            actor.peers.get(&a).unwrap().bdp_cap
13669        );
13670    }
13671
13672    #[tokio::test]
13673    async fn m257f_block_rtt_sample_feeds_avg_rtt_ewma() {
13674        // M257f RTT repair: M104 deleted the peer-task `request_sent`
13675        // call, so the actor-side `request_times` map stayed empty and
13676        // `avg_rtt` was NEVER populated (the BDP formula's no-RTT arm
13677        // would run for every peer, forever). RTT now arrives carried
13678        // on the block batch — this pins the actor-side EWMA wiring.
13679        let mut actor = TorrentActor::for_throttle_test(8, 0);
13680        let addr1 = addr(1, 6881);
13681        inject_budget_peer(&mut actor, addr1, 30_000);
13682        assert_eq!(
13683            actor.peers.get(&addr1).unwrap().avg_rtt,
13684            None,
13685            "no block delivered yet — no RTT sample"
13686        );
13687
13688        // First carried sample seeds the EWMA exactly (unwrap_or path).
13689        actor
13690            .process_block_completion(addr1, 0, 0, 16384, Some(Duration::from_millis(200)))
13691            .await;
13692        let r1 = actor.peers.get(&addr1).unwrap().avg_rtt.unwrap();
13693        assert!((r1 - 0.200).abs() < 1e-9, "first sample seeds EWMA: {r1}");
13694
13695        // Second sample blends: 0.3 × 0.1 + 0.7 × 0.2 = 0.17.
13696        actor
13697            .process_block_completion(addr1, 0, 16384, 16384, Some(Duration::from_millis(100)))
13698            .await;
13699        let r2 = actor.peers.get(&addr1).unwrap().avg_rtt.unwrap();
13700        assert!((r2 - 0.170).abs() < 1e-9, "EWMA blend alpha 0.3: {r2}");
13701
13702        // A block without a tracked send leaves the EWMA untouched.
13703        actor
13704            .process_block_completion(addr1, 0, 32768, 16384, None)
13705            .await;
13706        let r3 = actor.peers.get(&addr1).unwrap().avg_rtt.unwrap();
13707        assert!((r3 - 0.170).abs() < 1e-9, "None sample must not move it");
13708    }
13709}