Skip to main content

irontide_session/
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(crate) 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    #[allow(clippy::too_many_arguments)]
185    pub(crate) async fn from_torrent(
186        meta: TorrentMetaV1,
187        version: irontide_core::TorrentVersion,
188        meta_v2: Option<irontide_core::TorrentMetaV2>,
189        disk: DiskHandle,
190        disk_manager: DiskManagerHandle,
191        config: TorrentConfig,
192        dht_rx: irontide_dht::DhtReceiver,
193        dht_v6_rx: irontide_dht::DhtReceiver,
194        global_upload_bucket: Option<SharedBucket>,
195        global_download_bucket: Option<SharedBucket>,
196        slot_tuner: crate::slot_tuner::SlotTuner,
197        alert_tx: broadcast::Sender<Alert>,
198        alert_mask: Arc<AtomicU32>,
199        utp_socket: Option<irontide_utp::UtpSocket>,
200        utp_socket_v6: Option<irontide_utp::UtpSocket>,
201        ban_manager: crate::session::SharedBanManager,
202        ip_filter: crate::session::SharedIpFilter,
203        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
204        sam_session: Option<Arc<crate::i2p::SamSession>>,
205        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
206        factory: Arc<crate::transport::NetworkFactory>,
207        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
208        counters: Arc<crate::stats::SessionCounters>,
209    ) -> crate::Result<Self> {
210        let mut config = config;
211        // BEP 27: private torrents disable DHT, PEX, and LSD
212        if meta.info.private == Some(1) {
213            config.enable_dht = false;
214            config.enable_pex = false;
215            config.enable_lsd = false;
216        }
217
218        let info_hashes = match (&version, &meta_v2) {
219            (irontide_core::TorrentVersion::Hybrid, Some(v2_meta)) => {
220                if let Some(v2_hash) = v2_meta.info_hashes.v2 {
221                    irontide_core::InfoHashes::hybrid(meta.info_hash, v2_hash)
222                } else {
223                    irontide_core::InfoHashes::v1_only(meta.info_hash)
224                }
225            }
226            (irontide_core::TorrentVersion::V2Only, Some(v2_meta)) => v2_meta.info_hashes.clone(),
227            _ => irontide_core::InfoHashes::v1_only(meta.info_hash),
228        };
229
230        if meta.info.piece_length > config.max_piece_length {
231            return Err(crate::Error::InvalidSettings(format!(
232                "piece_length {} exceeds max_piece_length {}",
233                meta.info.piece_length, config.max_piece_length
234            )));
235        }
236
237        let num_pieces = meta.info.num_pieces() as u32;
238        let lengths = Lengths::new(
239            meta.info.total_length(),
240            meta.info.piece_length,
241            DEFAULT_CHUNK_SIZE,
242        );
243        let mut chunk_tracker = ChunkTracker::new(lengths.clone());
244
245        // Initialize HashPicker for v2/hybrid torrents and enable v2 block tracking
246        let hash_picker = if version.has_v2() {
247            if let Some(ref v2_meta) = meta_v2 {
248                chunk_tracker.enable_v2_tracking();
249
250                let block_size = 16384u64;
251                let blocks_per_piece = (meta.info.piece_length / block_size) as u32;
252
253                // Build FileHashInfo from v2 file tree
254                let v2_files = v2_meta.info.files();
255                let file_infos: Vec<irontide_core::FileHashInfo> = v2_files
256                    .iter()
257                    .filter_map(|f| {
258                        let root = f.attr.pieces_root?;
259                        let num_blocks = f.attr.length.div_ceil(block_size) as u32;
260                        let num_pieces = f.attr.length.div_ceil(meta.info.piece_length) as u32;
261                        Some(irontide_core::FileHashInfo {
262                            root,
263                            num_blocks,
264                            num_pieces,
265                        })
266                    })
267                    .collect();
268
269                if file_infos.is_empty() {
270                    None
271                } else {
272                    let mut picker = irontide_core::HashPicker::new(&file_infos, blocks_per_piece);
273
274                    // Pre-load piece-layer hashes from the .torrent file
275                    let _verified = picker.load_piece_layers(&v2_meta.piece_layers);
276
277                    Some(picker)
278                }
279            } else {
280                None
281            }
282        } else {
283            None
284        };
285
286        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
287        let file_priorities = vec![FilePriority::Normal; file_lengths.len()];
288        let wanted_pieces =
289            crate::piece_selector::build_wanted_pieces(&file_priorities, &file_lengths, &lengths);
290
291        let (cmd_tx, cmd_rx) = mpsc::channel(256);
292        let (event_tx, event_rx) = mpsc::channel(2048);
293        let (write_error_tx, write_error_rx) = mpsc::channel(64);
294        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
295        let (hash_result_tx, hash_result_rx) = mpsc::channel(64); // M96
296        let our_peer_id = if config.anonymous_mode {
297            PeerId::generate_anonymous().0
298        } else {
299            PeerId::generate().0
300        };
301
302        // Bind listener for incoming connections
303        // Try dual-stack [::]:port first, fall back to IPv4-only
304        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
305            .bind_tcp(SocketAddr::from((
306                std::net::Ipv6Addr::UNSPECIFIED,
307                config.listen_port,
308            )))
309            .await
310        {
311            Ok(l) => Some(l),
312            Err(_) => factory
313                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
314                .await
315                .ok(),
316        };
317        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
318
319        let mut tracker_manager = TrackerManager::from_torrent_filtered(
320            &meta,
321            our_peer_id,
322            config.listen_port,
323            config.url_security,
324            config.peer_dscp,
325            config.anonymous_mode,
326        );
327        tracker_manager.set_info_hashes(info_hashes.clone());
328
329        // BEP 7: include our I2P destination in tracker announces
330        if let Some(ref sam) = sam_session {
331            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
332        }
333
334        let enable_dht = config.enable_dht;
335
336        // M173 Lane B (B6): snapshot the broadcast at construction time
337        // to seed the initial peer-discovery channels. Future DHT
338        // restarts (B11) deliver peer batches via the watch
339        // subscription, not the initial snapshot.
340        let dht_initial = dht_rx.current();
341        let dht_v6_initial = dht_v6_rx.current();
342
343        // Start DHT peer discovery if enabled and available
344        let dht_peers_rx = if enable_dht {
345            if let Some(ref dht) = dht_initial {
346                match dht.get_peers(meta.info_hash).await {
347                    Ok(rx) => Some(rx),
348                    Err(e) => {
349                        warn!("failed to start DHT v4 get_peers: {e}");
350                        None
351                    }
352                }
353            } else {
354                None
355            }
356        } else {
357            None
358        };
359
360        let dht_v6_peers_rx = if enable_dht {
361            if let Some(ref dht6) = dht_v6_initial {
362                match dht6.get_peers(meta.info_hash).await {
363                    Ok(rx) => Some(rx),
364                    Err(e) => {
365                        debug!("failed to start DHT v6 get_peers: {e}");
366                        None
367                    }
368                }
369            } else {
370                None
371            }
372        } else {
373            None
374        };
375
376        // Dual-swarm: also search for v2 hash peers if hybrid
377        let v2_as_v1 = if info_hashes.is_hybrid() {
378            info_hashes
379                .v2
380                .map(|v2| Id20(v2.0[..20].try_into().unwrap()))
381        } else {
382            None
383        };
384        let (dht_v2_peers_rx, dht_v6_v2_peers_rx) =
385            if let (true, Some(v2_id)) = (enable_dht, v2_as_v1) {
386                let rx4 = if let Some(ref dht) = dht_initial {
387                    dht.get_peers(v2_id).await.ok()
388                } else {
389                    None
390                };
391                let rx6 = if let Some(ref dht6) = dht_v6_initial {
392                    dht6.get_peers(v2_id).await.ok()
393                } else {
394                    None
395                };
396                (rx4, rx6)
397            } else {
398                (None, None)
399            };
400
401        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
402        let download_bucket = Arc::new(parking_lot::Mutex::new(
403            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
404        ));
405        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
406            0,
407            0,
408            0,
409            0,
410            config.upload_rate_limit,
411            config.download_rate_limit,
412        );
413
414        let super_seed = if config.super_seeding {
415            Some(crate::super_seed::SuperSeedState::new())
416        } else {
417            None
418        };
419        // M118: broadcast channel for Have distribution — capacity scales with torrent size
420        let (have_broadcast_tx, _) =
421            tokio::sync::broadcast::channel(std::cmp::max(128, num_pieces as usize / 4));
422        let is_share_mode = config.share_mode;
423
424        let (piece_ready_tx, _) = broadcast::channel(64);
425        let initial_have = chunk_tracker.bitfield().clone();
426        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(initial_have);
427        let stream_read_semaphore =
428            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
429
430        let choker = Choker::with_algorithms(
431            initial_unchoke_slots(config.max_uploads_per_torrent),
432            config.seed_choking_algorithm,
433            config.choking_algorithm,
434            config.upload_rate_limit,
435            2,
436            20,
437        );
438
439        // M96: Wire hash pool into disk handle for V1-only torrents
440        let mut disk = disk;
441        if matches!(version, irontide_core::TorrentVersion::V1Only)
442            && let Some(pool) = &hash_pool
443        {
444            disk.set_hash_pool(pool.clone());
445            disk.set_hash_result_tx(hash_result_tx.clone());
446        }
447
448        // M116: Pre-compute file->piece mapping for zero-alloc completion checks.
449        let cached_files = Some(build_cached_file_info(&meta, &lengths));
450
451        // v0.173.4 (prong 1): seed the snapshot watch with an empty initial
452        let (order_map_tx, _order_map_rx_seed) =
453            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
454
455        let actor = TorrentActor {
456            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
457                config.lock_warn_threshold_ms,
458            ),
459            config,
460            info_hash: meta.info_hash,
461            our_peer_id,
462            state: TorrentState::Downloading,
463            disk: Some(disk),
464            disk_manager,
465            chunk_tracker: Some(chunk_tracker),
466            lengths: Some(lengths),
467            num_pieces,
468            streaming_pieces: BTreeSet::new(),
469            time_critical_pieces: BTreeSet::new(),
470            streaming_cursors: Vec::new(),
471            piece_ready_tx,
472            have_watch_tx,
473            have_watch_rx,
474            stream_read_semaphore,
475            file_priorities,
476            wanted_pieces,
477            end_game: EndGame::new(),
478            peers: HashMap::new(),
479            unchoke_durations: HashMap::new(),
480            cached_peer_rates: FxHashMap::default(),
481            refill_notify: Arc::new(tokio::sync::Notify::new()),
482            atomic_states: None,
483            block_maps: None,
484            steal_candidates: None,
485            last_steal_populate: Instant::now(),
486            piece_write_guards: None,
487            soft_reap_buf: Vec::new(),
488            eviction_history: std::collections::VecDeque::new(),
489            force_immediate_choker_tick: false,
490            piece_tracker: None,
491            order_map_tx,
492            piece_owner: Vec::new(),
493            peer_slab: crate::piece_reservation::PeerSlab::new(),
494            priority_pieces: BTreeSet::new(),
495            max_in_flight: 512,
496            reservation_notify: None,
497            last_tick_dispatch_state: None,
498            choker,
499            user_seed_mode: false,
500            user_forced: false,
501            max_connections: 0,
502            peer_states: None,
503            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
504            connect_permits: HashMap::new(),
505            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
506                std::collections::HashMap::new(),
507            )),
508            connect_rx: None,
509            metadata_downloader: None,
510            downloaded: 0,
511            uploaded: 0,
512            checking_progress: 0.0,
513            total_download: 0,
514            total_upload: 0,
515            total_failed_bytes: 0,
516            total_redundant_bytes: 0,
517            added_time: std::time::SystemTime::now()
518                .duration_since(std::time::UNIX_EPOCH)
519                .map_or(0, |d| d.as_secs() as i64),
520            completed_time: 0,
521            last_download: 0,
522            last_upload: 0,
523            last_seen_complete: 0,
524            active_duration: 0,
525            finished_duration: 0,
526            seeding_duration: 0,
527            active_since: Some(std::time::Instant::now()),
528            state_duration_since: None,
529            started_at: std::time::Instant::now(),
530            moving_storage: false,
531            has_incoming: false,
532            need_save_resume: false,
533            error: String::new(),
534            error_file: -1,
535            cmd_rx,
536            event_tx,
537            event_rx,
538            write_error_rx,
539            write_error_tx,
540            verify_result_rx,
541            verify_result_tx,
542            pending_verify: HashSet::new(),
543            piece_generations: vec![0u64; num_pieces as usize],
544            hash_result_rx,
545            hash_result_tx,
546            meta: Some(meta),
547            cached_files,
548            listener,
549            utp_socket,
550            utp_socket_v6,
551            tracker_manager,
552            tracker_result_rx: None,
553            dht_rx,
554            dht_v6_rx,
555            dht_enabled: enable_dht,
556            dht_peers_rx,
557            dht_v6_peers_rx,
558            dht_v6_empty_count: 0,
559            dht_v6_last_retry: None,
560            alert_tx,
561            alert_mask,
562            upload_bucket,
563            download_bucket,
564            global_upload_bucket,
565            global_download_bucket,
566            slot_tuner,
567            upload_bytes_interval: 0,
568            peak_download_rate: 0,
569            web_seeds: HashMap::new(),
570            banned_web_seeds: HashSet::new(),
571            web_seed_in_flight: HashMap::new(),
572            web_seed_stats: HashMap::new(),
573            pex_peer_count: 0,
574            lsd_peer_count: 0,
575            super_seed,
576            have_broadcast_tx,
577            suggested_to_peers: HashMap::new(),
578            predictive_have_sent: HashSet::new(),
579
580            ban_manager,
581            ip_filter,
582            piece_contributors: HashMap::new(),
583            parole_pieces: HashMap::new(),
584            external_ip: None,
585            share_lru: std::collections::VecDeque::new(),
586            share_max_pieces: if is_share_mode { 64 } else { 0 },
587            plugins,
588            hash_picker,
589            version,
590            meta_v2,
591            info_hashes,
592            dht_v2_peers_rx,
593            dht_v6_v2_peers_rx,
594            magnet_selected_files: None,
595            sam_session,
596            i2p_accept_rx: None,
597            i2p_peer_counter: 0,
598            i2p_destinations: HashMap::new(),
599            ssl_manager,
600            rate_limiter_set,
601            auto_sequential_active: false,
602            factory,
603            hash_pool_ref: hash_pool,
604            connect_attempts: 0,
605            connect_failures: 0,
606            choke_rotations: 0,
607            inflight_started: Vec::new(),
608            completed_piece_times: std::collections::VecDeque::new(),
609            piece_steals: 0,
610            holepunch_relayed: 0,
611            holepunch_relay_rate: HashMap::new(),
612            holepunch_cooldowns: HashMap::new(),
613            holepunch_pending: Vec::new(),
614            counters,
615        };
616
617        let spawn_info_hash = actor.info_hash;
618        let join_handle = tokio::spawn(actor.run());
619        // Monitor the actor task so panics/exits are logged instead of silently swallowed.
620        tokio::spawn(async move {
621            match join_handle.await {
622                Ok(()) => {
623                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
624                }
625                Err(e) if e.is_panic() => {
626                    let panic_payload = e.into_panic();
627                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
628                        (*s).to_string()
629                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
630                        s.clone()
631                    } else {
632                        "unknown panic payload".to_string()
633                    };
634                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
635                }
636                Err(e) => {
637                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
638                }
639            }
640        });
641        Ok(Self { cmd_tx })
642    }
643
644    /// Create a torrent session from a magnet link (metadata fetched via BEP 9).
645    ///
646    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
647    /// from the session-level [`irontide_dht::DhtBroadcast`]; see
648    /// [`Self::from_torrent`] for the rationale.
649    #[allow(clippy::too_many_arguments)]
650    pub(crate) async fn from_magnet(
651        magnet: Magnet,
652        disk_manager: DiskManagerHandle,
653        config: TorrentConfig,
654        dht_rx: irontide_dht::DhtReceiver,
655        dht_v6_rx: irontide_dht::DhtReceiver,
656        global_upload_bucket: Option<SharedBucket>,
657        global_download_bucket: Option<SharedBucket>,
658        slot_tuner: crate::slot_tuner::SlotTuner,
659        alert_tx: broadcast::Sender<Alert>,
660        alert_mask: Arc<AtomicU32>,
661        utp_socket: Option<irontide_utp::UtpSocket>,
662        utp_socket_v6: Option<irontide_utp::UtpSocket>,
663        ban_manager: crate::session::SharedBanManager,
664        ip_filter: crate::session::SharedIpFilter,
665        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
666        sam_session: Option<Arc<crate::i2p::SamSession>>,
667        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
668        factory: Arc<crate::transport::NetworkFactory>,
669        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
670        counters: Arc<crate::stats::SessionCounters>,
671    ) -> crate::Result<Self> {
672        let (cmd_tx, cmd_rx) = mpsc::channel(256);
673        let (event_tx, event_rx) = mpsc::channel(2048);
674        let (write_error_tx, write_error_rx) = mpsc::channel(64);
675        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
676        // M96: Dummy channel — replaced when metadata arrives and num_pieces is known
677        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
678        let our_peer_id = if config.anonymous_mode {
679            PeerId::generate_anonymous().0
680        } else {
681            PeerId::generate().0
682        };
683
684        // Try dual-stack [::]:port first, fall back to IPv4-only
685        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
686            .bind_tcp(SocketAddr::from((
687                std::net::Ipv6Addr::UNSPECIFIED,
688                config.listen_port,
689            )))
690            .await
691        {
692            Ok(l) => Some(l),
693            Err(_) => factory
694                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
695                .await
696                .ok(),
697        };
698        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
699
700        let mut tracker_manager = TrackerManager::empty(
701            magnet.info_hash(),
702            our_peer_id,
703            config.listen_port,
704            config.peer_dscp,
705            config.anonymous_mode,
706        );
707        // Add tracker URLs from the magnet link (BEP 9 §3.1)
708        for url in &magnet.trackers {
709            tracker_manager.add_tracker_url(url);
710        }
711
712        // BEP 7: include our I2P destination in tracker announces
713        if let Some(ref sam) = sam_session {
714            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
715        }
716
717        let enable_dht = config.enable_dht;
718
719        // M173 Lane B (B6): snapshot the broadcast at construction time
720        // for the initial peer-discovery wiring; future DHT restarts
721        // (B11) deliver new handles via the watch subscription.
722        let dht_initial = dht_rx.current();
723        let dht_v6_initial = dht_v6_rx.current();
724
725        // Start DHT peer discovery if enabled and available
726        let dht_peers_rx = if enable_dht {
727            if let Some(ref dht) = dht_initial {
728                match dht.get_peers(magnet.info_hash()).await {
729                    Ok(rx) => Some(rx),
730                    Err(e) => {
731                        warn!("failed to start DHT v4 get_peers: {e}");
732                        None
733                    }
734                }
735            } else {
736                None
737            }
738        } else {
739            None
740        };
741
742        let dht_v6_peers_rx = if enable_dht {
743            if let Some(ref dht6) = dht_v6_initial {
744                match dht6.get_peers(magnet.info_hash()).await {
745                    Ok(rx) => Some(rx),
746                    Err(e) => {
747                        debug!("failed to start DHT v6 get_peers: {e}");
748                        None
749                    }
750                }
751            } else {
752                None
753            }
754        } else {
755            None
756        };
757
758        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
759        let download_bucket = Arc::new(parking_lot::Mutex::new(
760            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
761        ));
762        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
763            0,
764            0,
765            0,
766            0,
767            config.upload_rate_limit,
768            config.download_rate_limit,
769        );
770
771        let super_seed = if config.super_seeding {
772            Some(crate::super_seed::SuperSeedState::new())
773        } else {
774            None
775        };
776        // M118: broadcast channel — start with min capacity for magnet (resized on metadata)
777        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
778        let is_share_mode = config.share_mode;
779        let magnet_selected_files = magnet.selected_files.clone();
780        let info_hashes = magnet.info_hashes.clone();
781
782        let (piece_ready_tx, _) = broadcast::channel(64);
783        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(0));
784        let stream_read_semaphore =
785            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
786
787        let choker = Choker::with_algorithms(
788            initial_unchoke_slots(config.max_uploads_per_torrent),
789            config.seed_choking_algorithm,
790            config.choking_algorithm,
791            config.upload_rate_limit,
792            2,
793            20,
794        );
795
796        let (order_map_tx, _order_map_rx_seed) =
797            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
798
799        let actor = TorrentActor {
800            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
801                config.lock_warn_threshold_ms,
802            ),
803            config,
804            info_hash: magnet.info_hash(),
805            our_peer_id,
806            state: TorrentState::FetchingMetadata,
807            disk: None,
808            disk_manager,
809            chunk_tracker: None,
810            lengths: None,
811            num_pieces: 0,
812            streaming_pieces: BTreeSet::new(),
813            time_critical_pieces: BTreeSet::new(),
814            streaming_cursors: Vec::new(),
815            piece_ready_tx,
816            have_watch_tx,
817            have_watch_rx,
818            stream_read_semaphore,
819            file_priorities: Vec::new(),
820            wanted_pieces: Bitfield::new(0),
821            end_game: EndGame::new(),
822            peers: HashMap::new(),
823            unchoke_durations: HashMap::new(),
824            cached_peer_rates: FxHashMap::default(),
825            refill_notify: Arc::new(tokio::sync::Notify::new()),
826            atomic_states: None,
827            block_maps: None,
828            steal_candidates: None,
829            last_steal_populate: Instant::now(),
830            piece_write_guards: None,
831            soft_reap_buf: Vec::new(),
832            eviction_history: std::collections::VecDeque::new(),
833            force_immediate_choker_tick: false,
834            piece_tracker: None,
835            order_map_tx,
836            piece_owner: Vec::new(),
837            peer_slab: crate::piece_reservation::PeerSlab::new(),
838            priority_pieces: BTreeSet::new(),
839            max_in_flight: 512,
840            reservation_notify: None,
841            last_tick_dispatch_state: None,
842            choker,
843            user_seed_mode: false,
844            user_forced: false,
845            max_connections: 0,
846            peer_states: None,
847            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
848            connect_permits: HashMap::new(),
849            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
850                std::collections::HashMap::new(),
851            )),
852            connect_rx: None,
853            metadata_downloader: Some(MetadataDownloader::new(magnet.info_hash())),
854            downloaded: 0,
855            uploaded: 0,
856            checking_progress: 0.0,
857            total_download: 0,
858            total_upload: 0,
859            total_failed_bytes: 0,
860            total_redundant_bytes: 0,
861            added_time: std::time::SystemTime::now()
862                .duration_since(std::time::UNIX_EPOCH)
863                .map_or(0, |d| d.as_secs() as i64),
864            completed_time: 0,
865            last_download: 0,
866            last_upload: 0,
867            last_seen_complete: 0,
868            active_duration: 0,
869            finished_duration: 0,
870            seeding_duration: 0,
871            active_since: Some(std::time::Instant::now()),
872            state_duration_since: None,
873            started_at: std::time::Instant::now(),
874            moving_storage: false,
875            has_incoming: false,
876            need_save_resume: false,
877            error: String::new(),
878            error_file: -1,
879            cmd_rx,
880            event_tx,
881            event_rx,
882            write_error_rx,
883            write_error_tx,
884            verify_result_rx,
885            verify_result_tx,
886            pending_verify: HashSet::new(),
887            piece_generations: Vec::new(),
888            hash_result_rx,
889            hash_result_tx,
890            meta: None,
891            cached_files: None,
892            listener,
893            utp_socket,
894            utp_socket_v6,
895            tracker_manager,
896            tracker_result_rx: None,
897            dht_rx,
898            dht_v6_rx,
899            dht_enabled: enable_dht,
900            dht_peers_rx,
901            dht_v6_peers_rx,
902            dht_v6_empty_count: 0,
903            dht_v6_last_retry: None,
904            alert_tx,
905            alert_mask,
906            upload_bucket,
907            download_bucket,
908            global_upload_bucket,
909            global_download_bucket,
910            slot_tuner,
911            upload_bytes_interval: 0,
912            peak_download_rate: 0,
913            web_seeds: HashMap::new(),
914            banned_web_seeds: HashSet::new(),
915            web_seed_in_flight: HashMap::new(),
916            web_seed_stats: HashMap::new(),
917            pex_peer_count: 0,
918            lsd_peer_count: 0,
919            super_seed,
920            have_broadcast_tx,
921            suggested_to_peers: HashMap::new(),
922            predictive_have_sent: HashSet::new(),
923
924            ban_manager,
925            ip_filter,
926            piece_contributors: HashMap::new(),
927            parole_pieces: HashMap::new(),
928            external_ip: None,
929            share_lru: std::collections::VecDeque::new(),
930            share_max_pieces: if is_share_mode { 64 } else { 0 },
931            plugins,
932            hash_picker: None,
933            version: irontide_core::TorrentVersion::V1Only,
934            meta_v2: None,
935            info_hashes,
936            dht_v2_peers_rx: None,
937            dht_v6_v2_peers_rx: None,
938            magnet_selected_files,
939            sam_session,
940            i2p_accept_rx: None,
941            i2p_peer_counter: 0,
942            i2p_destinations: HashMap::new(),
943            ssl_manager,
944            rate_limiter_set,
945            auto_sequential_active: false,
946            factory,
947            hash_pool_ref: hash_pool,
948            connect_attempts: 0,
949            connect_failures: 0,
950            choke_rotations: 0,
951            inflight_started: Vec::new(),
952            completed_piece_times: std::collections::VecDeque::new(),
953            piece_steals: 0,
954            holepunch_relayed: 0,
955            holepunch_relay_rate: HashMap::new(),
956            holepunch_cooldowns: HashMap::new(),
957            holepunch_pending: Vec::new(),
958            counters,
959        };
960
961        let spawn_info_hash = actor.info_hash;
962        let join_handle = tokio::spawn(actor.run());
963        tokio::spawn(async move {
964            match join_handle.await {
965                Ok(()) => {
966                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
967                }
968                Err(e) if e.is_panic() => {
969                    let panic_payload = e.into_panic();
970                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
971                        (*s).to_string()
972                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
973                        s.clone()
974                    } else {
975                        "unknown panic payload".to_string()
976                    };
977                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
978                }
979                Err(e) => {
980                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
981                }
982            }
983        });
984        Ok(Self { cmd_tx })
985    }
986
987    /// Send an incoming peer (routed by the session) to this torrent.
988    pub(crate) async fn send_incoming_peer(
989        &self,
990        stream: crate::transport::BoxedStream,
991        addr: SocketAddr,
992    ) -> crate::Result<()> {
993        self.cmd_tx
994            .send(TorrentCommand::IncomingPeer { stream, addr })
995            .await
996            .map_err(|_| crate::Error::Shutdown)
997    }
998
999    /// Query current torrent statistics.
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error if the session is shut down.
1004    pub async fn stats(&self) -> crate::Result<TorrentStats> {
1005        let (tx, rx) = oneshot::channel();
1006        self.cmd_tx
1007            .send(TorrentCommand::Stats { reply: tx })
1008            .await
1009            .map_err(|_| crate::Error::Shutdown)?;
1010        rx.await.map_err(|_| crate::Error::Shutdown)
1011    }
1012
1013    /// v0.173.1: fetch the torrent's current metadata from the `TorrentActor`.
1014    ///
1015    /// Returns `Ok(None)` if metadata has not yet been assembled (magnet
1016    /// pre-resolution), `Ok(Some(meta))` once the info dict is known. Returns
1017    /// `Err(Shutdown)` if the actor has already exited. Callers that need the
1018    /// meta to exist should fall back to `crate::Error::MetadataNotReady` on
1019    /// the `Ok(None)` branch.
1020    ///
1021    /// This replaces `SessionActor.TorrentEntry.meta` as the single source of
1022    /// truth: see `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`
1023    /// for the Class A archaeology.
1024    ///
1025    /// # Errors
1026    ///
1027    /// Returns an error if the session is shut down.
1028    pub async fn get_meta(&self) -> crate::Result<Option<TorrentMetaV1>> {
1029        let (tx, rx) = oneshot::channel();
1030        self.cmd_tx
1031            .send(TorrentCommand::GetMeta { reply: tx })
1032            .await
1033            .map_err(|_| crate::Error::Shutdown)?;
1034        rx.await.map_err(|_| crate::Error::Shutdown)
1035    }
1036
1037    /// Add peer addresses to the available-peer pool.
1038    ///
1039    /// # Errors
1040    ///
1041    /// Returns an error if the session is shut down.
1042    pub async fn add_peers(&self, peers: Vec<SocketAddr>, source: PeerSource) -> crate::Result<()> {
1043        self.cmd_tx
1044            .send(TorrentCommand::AddPeers { peers, source })
1045            .await
1046            .map_err(|_| crate::Error::Shutdown)
1047    }
1048
1049    /// Pause the torrent session (disconnect peers, announce Stopped).
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns an error if the session is shut down.
1054    pub async fn pause(&self) -> crate::Result<()> {
1055        self.cmd_tx
1056            .send(TorrentCommand::Pause)
1057            .await
1058            .map_err(|_| crate::Error::Shutdown)
1059    }
1060
1061    /// Queue a torrent via auto-manage (system-managed pause).
1062    ///
1063    /// # Errors
1064    ///
1065    /// Returns an error if the session is shut down.
1066    pub async fn queue(&self) -> crate::Result<()> {
1067        self.cmd_tx
1068            .send(TorrentCommand::Queue)
1069            .await
1070            .map_err(|_| crate::Error::Shutdown)
1071    }
1072
1073    /// M170: update the qBt-compat category label on this torrent.
1074    ///
1075    /// Pass `None` to clear the label. The change is visible via the
1076    /// next `stats()` call and persists across `save_resume_data`.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns an error if the session is shut down.
1081    pub async fn set_category(&self, category: Option<String>) -> crate::Result<()> {
1082        let (tx, rx) = oneshot::channel();
1083        self.cmd_tx
1084            .send(TorrentCommand::SetCategory {
1085                category,
1086                reply: tx,
1087            })
1088            .await
1089            .map_err(|_| crate::Error::Shutdown)?;
1090        rx.await.map_err(|_| crate::Error::Shutdown)
1091    }
1092
1093    /// M171: replace this torrent's tag set wholesale (qBt-compat).
1094    ///
1095    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
1096    /// layer — always a wholesale replacement at the engine layer. The
1097    /// change is visible via the next `stats()` call and persists
1098    /// across `save_resume_data`.
1099    ///
1100    /// # Errors
1101    ///
1102    /// Returns an error if the session is shut down.
1103    pub async fn set_tags(&self, tags: Vec<String>) -> crate::Result<()> {
1104        let (tx, rx) = oneshot::channel();
1105        self.cmd_tx
1106            .send(TorrentCommand::SetTags { tags, reply: tx })
1107            .await
1108            .map_err(|_| crate::Error::Shutdown)?;
1109        rx.await.map_err(|_| crate::Error::Shutdown)
1110    }
1111
1112    /// Resume a paused torrent session (reconnect, announce Started).
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns an error if the session is shut down.
1117    pub async fn resume(&self) -> crate::Result<()> {
1118        self.cmd_tx
1119            .send(TorrentCommand::Resume)
1120            .await
1121            .map_err(|_| crate::Error::Shutdown)
1122    }
1123
1124    /// Gracefully shut down the torrent session.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Returns an error if the session is shut down.
1129    pub async fn shutdown(&self) -> crate::Result<()> {
1130        // Best-effort send with timeout — if the channel is full or closed,
1131        // the actor will exit when all senders are dropped anyway.
1132        let _ = tokio::time::timeout(
1133            std::time::Duration::from_secs(5),
1134            self.cmd_tx.send(TorrentCommand::Shutdown),
1135        )
1136        .await;
1137        Ok(())
1138    }
1139
1140    /// Snapshot current torrent state into libtorrent-compatible resume data.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns an error if the I/O operation fails.
1145    pub async fn save_resume_data(&self) -> crate::Result<irontide_core::FastResumeData> {
1146        let (tx, rx) = oneshot::channel();
1147        self.cmd_tx
1148            .send(TorrentCommand::SaveResumeData { reply: tx })
1149            .await
1150            .map_err(|_| crate::Error::Shutdown)?;
1151        rx.await.map_err(|_| crate::Error::Shutdown)?
1152    }
1153
1154    /// Clear the `need_save_resume` dirty flag after a successful file save.
1155    pub(crate) async fn clear_save_resume_flag(&self) -> crate::Result<()> {
1156        self.cmd_tx
1157            .send(TorrentCommand::ClearSaveResumeFlag)
1158            .await
1159            .map_err(|_| crate::Error::Shutdown)
1160    }
1161
1162    /// Restore a piece bitmap from resume data (M161 Phase 4).
1163    ///
1164    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
1165    /// Returns an error if the bitfield length does not match the torrent's
1166    /// piece count or if the chunk tracker is not yet initialized.
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1171    /// Returns [`crate::Error::InvalidSettings`] if the bitfield is invalid.
1172    pub(crate) async fn restore_resume_bitmap(&self, pieces: Vec<u8>) -> crate::Result<()> {
1173        let (tx, rx) = oneshot::channel();
1174        self.cmd_tx
1175            .send(TorrentCommand::RestoreResumeBitmap { pieces, reply: tx })
1176            .await
1177            .map_err(|_| crate::Error::Shutdown)?;
1178        rx.await.map_err(|_| crate::Error::Shutdown)?
1179    }
1180
1181    /// M178: Restore per-URL web-seed stats from resume data.
1182    ///
1183    /// # Errors
1184    ///
1185    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1186    pub(crate) async fn restore_web_seed_stats(
1187        &self,
1188        stats: HashMap<String, irontide_core::WebSeedStats>,
1189    ) -> crate::Result<()> {
1190        let (tx, rx) = oneshot::channel();
1191        self.cmd_tx
1192            .send(TorrentCommand::RestoreWebSeedStats { stats, reply: tx })
1193            .await
1194            .map_err(|_| crate::Error::Shutdown)?;
1195        rx.await.map_err(|_| crate::Error::Shutdown)?
1196    }
1197
1198    /// M178 Lane B3: cumulative `(pex_peer_count, lsd_peer_count)` for
1199    /// this torrent. Both counters track UNIQUE peers — duplicate
1200    /// announcements from the same peer count once.
1201    ///
1202    /// # Errors
1203    ///
1204    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1205    pub(crate) async fn peer_source_counts(&self) -> crate::Result<(usize, usize)> {
1206        let (tx, rx) = oneshot::channel();
1207        self.cmd_tx
1208            .send(TorrentCommand::GetPeerSourceCounts { reply: tx })
1209            .await
1210            .map_err(|_| crate::Error::Shutdown)?;
1211        rx.await.map_err(|_| crate::Error::Shutdown)
1212    }
1213
1214    /// Snapshot the per-peer cumulative unchoke duration for this torrent.
1215    ///
1216    /// # Errors
1217    ///
1218    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1219    pub(crate) async fn query_unchoke_durations(
1220        &self,
1221    ) -> crate::Result<HashMap<SocketAddr, std::time::Duration>> {
1222        let (tx, rx) = oneshot::channel();
1223        self.cmd_tx
1224            .send(TorrentCommand::QueryUnchokeDurations { reply: tx })
1225            .await
1226            .map_err(|_| crate::Error::Shutdown)?;
1227        rx.await.map_err(|_| crate::Error::Shutdown)
1228    }
1229
1230    /// M178 Lane C: snapshot per-URL `WebSeedStats` for this torrent.
1231    ///
1232    /// # Errors
1233    ///
1234    /// Returns [`crate::Error::Shutdown`] if the actor has stopped.
1235    pub(crate) async fn get_web_seed_stats(
1236        &self,
1237    ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
1238        let (tx, rx) = oneshot::channel();
1239        self.cmd_tx
1240            .send(TorrentCommand::GetWebSeedStats { reply: tx })
1241            .await
1242            .map_err(|_| crate::Error::Shutdown)?;
1243        rx.await.map_err(|_| crate::Error::Shutdown)
1244    }
1245
1246    /// Set the download priority for a specific file.
1247    ///
1248    /// # Errors
1249    ///
1250    /// Returns an error if the session is shut down.
1251    pub async fn set_file_priority(
1252        &self,
1253        index: usize,
1254        priority: irontide_core::FilePriority,
1255    ) -> crate::Result<()> {
1256        let (tx, rx) = oneshot::channel();
1257        self.cmd_tx
1258            .send(TorrentCommand::SetFilePriority {
1259                index,
1260                priority,
1261                reply: tx,
1262            })
1263            .await
1264            .map_err(|_| crate::Error::Shutdown)?;
1265        rx.await.map_err(|_| crate::Error::Shutdown)?
1266    }
1267
1268    /// Get the current per-file priorities.
1269    ///
1270    /// # Errors
1271    ///
1272    /// Returns an error if the session is shut down.
1273    pub async fn file_priorities(&self) -> crate::Result<Vec<irontide_core::FilePriority>> {
1274        let (tx, rx) = oneshot::channel();
1275        self.cmd_tx
1276            .send(TorrentCommand::FilePriorities { reply: tx })
1277            .await
1278            .map_err(|_| crate::Error::Shutdown)?;
1279        rx.await.map_err(|_| crate::Error::Shutdown)
1280    }
1281
1282    /// Get the list of all configured trackers with their status.
1283    ///
1284    /// # Errors
1285    ///
1286    /// Returns an error if the session is shut down.
1287    pub async fn tracker_list(&self) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
1288        let (tx, rx) = oneshot::channel();
1289        self.cmd_tx
1290            .send(TorrentCommand::TrackerList { reply: tx })
1291            .await
1292            .map_err(|_| crate::Error::Shutdown)?;
1293        rx.await.map_err(|_| crate::Error::Shutdown)
1294    }
1295
1296    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged).
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns an error if the session is shut down.
1301    pub async fn get_web_seeds(&self) -> crate::Result<Vec<String>> {
1302        let (tx, rx) = oneshot::channel();
1303        self.cmd_tx
1304            .send(TorrentCommand::GetWebSeeds { reply: tx })
1305            .await
1306            .map_err(|_| crate::Error::Shutdown)?;
1307        rx.await.map_err(|_| crate::Error::Shutdown)
1308    }
1309
1310    /// M171 Lane B: snapshot the per-piece qBt state codes.
1311    ///
1312    /// # Errors
1313    ///
1314    /// Returns an error if the session is shut down.
1315    pub async fn get_piece_states(&self) -> crate::Result<Vec<u8>> {
1316        let (tx, rx) = oneshot::channel();
1317        self.cmd_tx
1318            .send(TorrentCommand::GetPieceStates { reply: tx })
1319            .await
1320            .map_err(|_| crate::Error::Shutdown)?;
1321        rx.await.map_err(|_| crate::Error::Shutdown)
1322    }
1323
1324    /// M171 Lane B: paginated piece hash list.
1325    ///
1326    /// `offset` and `limit` are clamped to the real hash count inside
1327    /// the actor — callers can pass arbitrary values without overflow
1328    /// concerns.
1329    ///
1330    /// # Errors
1331    ///
1332    /// Returns an error if the session is shut down.
1333    pub async fn get_piece_hashes(&self, offset: u32, limit: u32) -> crate::Result<Vec<String>> {
1334        let (tx, rx) = oneshot::channel();
1335        self.cmd_tx
1336            .send(TorrentCommand::GetPieceHashes {
1337                offset,
1338                limit,
1339                reply: tx,
1340            })
1341            .await
1342            .map_err(|_| crate::Error::Shutdown)?;
1343        rx.await.map_err(|_| crate::Error::Shutdown)
1344    }
1345
1346    /// Force all trackers to re-announce immediately.
1347    ///
1348    /// # Errors
1349    ///
1350    /// Returns an error if the session is shut down.
1351    pub async fn force_reannounce(&self) -> crate::Result<()> {
1352        self.cmd_tx
1353            .send(TorrentCommand::ForceReannounce)
1354            .await
1355            .map_err(|_| crate::Error::Shutdown)
1356    }
1357
1358    /// Scrape trackers for seeder/leecher counts.
1359    ///
1360    /// # Errors
1361    ///
1362    /// Returns an error if the session is shut down.
1363    pub async fn scrape(&self) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
1364        let (tx, rx) = oneshot::channel();
1365        self.cmd_tx
1366            .send(TorrentCommand::Scrape { reply: tx })
1367            .await
1368            .map_err(|_| crate::Error::Shutdown)?;
1369        rx.await.map_err(|_| crate::Error::Shutdown)
1370    }
1371
1372    /// Open a streaming reader for a file within the torrent.
1373    ///
1374    /// # Errors
1375    ///
1376    /// Returns an error if the session is shut down.
1377    pub async fn open_file(
1378        &self,
1379        file_index: usize,
1380    ) -> crate::Result<crate::streaming::FileStream> {
1381        let (tx, rx) = oneshot::channel();
1382        self.cmd_tx
1383            .send(TorrentCommand::OpenFile {
1384                file_index,
1385                reply: tx,
1386            })
1387            .await
1388            .map_err(|_| crate::Error::Shutdown)?;
1389        let handle = rx.await.map_err(|_| crate::Error::Shutdown)??;
1390        Ok(crate::streaming::FileStream::from_handle(handle))
1391    }
1392
1393    /// Update the external IP for BEP 40 peer priority sorting.
1394    pub(crate) async fn update_external_ip(&self, ip: std::net::IpAddr) -> crate::Result<()> {
1395        self.cmd_tx
1396            .send(TorrentCommand::UpdateExternalIp { ip })
1397            .await
1398            .map_err(|_| crate::Error::Shutdown)
1399    }
1400
1401    /// Move torrent data files to a new download directory.
1402    ///
1403    /// Relocates existing files (rename or copy+delete), re-registers storage
1404    /// with the disk manager, and fires a `StorageMoved` alert on success.
1405    ///
1406    /// # Errors
1407    ///
1408    /// Returns an error if the session is shut down.
1409    pub async fn move_storage(&self, new_path: std::path::PathBuf) -> crate::Result<()> {
1410        let (tx, rx) = oneshot::channel();
1411        self.cmd_tx
1412            .send(TorrentCommand::MoveStorage {
1413                new_path,
1414                reply: tx,
1415            })
1416            .await
1417            .map_err(|_| crate::Error::Shutdown)?;
1418        rx.await.map_err(|_| crate::Error::Shutdown)?
1419    }
1420
1421    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns an error if the data cannot be parsed or I/O fails.
1426    pub async fn set_download_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1427        let (tx, rx) = oneshot::channel();
1428        self.cmd_tx
1429            .send(TorrentCommand::SetDownloadLimit {
1430                bytes_per_sec,
1431                reply: tx,
1432            })
1433            .await
1434            .map_err(|_| crate::Error::Shutdown)?;
1435        rx.await.map_err(|_| crate::Error::Shutdown)
1436    }
1437
1438    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
1439    ///
1440    /// # Errors
1441    ///
1442    /// Returns an error if the data cannot be parsed or I/O fails.
1443    pub async fn set_upload_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1444        let (tx, rx) = oneshot::channel();
1445        self.cmd_tx
1446            .send(TorrentCommand::SetUploadLimit {
1447                bytes_per_sec,
1448                reply: tx,
1449            })
1450            .await
1451            .map_err(|_| crate::Error::Shutdown)?;
1452        rx.await.map_err(|_| crate::Error::Shutdown)
1453    }
1454
1455    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
1456    ///
1457    /// # Errors
1458    ///
1459    /// Returns an error if the data cannot be parsed or I/O fails.
1460    pub async fn download_limit(&self) -> crate::Result<u64> {
1461        let (tx, rx) = oneshot::channel();
1462        self.cmd_tx
1463            .send(TorrentCommand::DownloadLimit { reply: tx })
1464            .await
1465            .map_err(|_| crate::Error::Shutdown)?;
1466        rx.await.map_err(|_| crate::Error::Shutdown)
1467    }
1468
1469    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
1470    ///
1471    /// # Errors
1472    ///
1473    /// Returns an error if the data cannot be parsed or I/O fails.
1474    pub async fn upload_limit(&self) -> crate::Result<u64> {
1475        let (tx, rx) = oneshot::channel();
1476        self.cmd_tx
1477            .send(TorrentCommand::UploadLimit { reply: tx })
1478            .await
1479            .map_err(|_| crate::Error::Shutdown)?;
1480        rx.await.map_err(|_| crate::Error::Shutdown)
1481    }
1482
1483    /// Enable or disable sequential (in-order) piece downloading.
1484    ///
1485    /// # Errors
1486    ///
1487    /// Returns an error if the data cannot be parsed or I/O fails.
1488    pub async fn set_sequential_download(&self, enabled: bool) -> crate::Result<()> {
1489        let (tx, rx) = oneshot::channel();
1490        self.cmd_tx
1491            .send(TorrentCommand::SetSequentialDownload { enabled, reply: tx })
1492            .await
1493            .map_err(|_| crate::Error::Shutdown)?;
1494        rx.await.map_err(|_| crate::Error::Shutdown)
1495    }
1496
1497    /// Query whether sequential downloading is enabled.
1498    ///
1499    /// # Errors
1500    ///
1501    /// Returns an error if the data cannot be parsed or I/O fails.
1502    pub async fn is_sequential_download(&self) -> crate::Result<bool> {
1503        let (tx, rx) = oneshot::channel();
1504        self.cmd_tx
1505            .send(TorrentCommand::IsSequentialDownload { reply: tx })
1506            .await
1507            .map_err(|_| crate::Error::Shutdown)?;
1508        rx.await.map_err(|_| crate::Error::Shutdown)
1509    }
1510
1511    /// Enable or disable BEP 16 super seeding mode.
1512    ///
1513    /// # Errors
1514    ///
1515    /// Returns an error if the session is shut down.
1516    pub async fn set_super_seeding(&self, enabled: bool) -> crate::Result<()> {
1517        let (tx, rx) = oneshot::channel();
1518        self.cmd_tx
1519            .send(TorrentCommand::SetSuperSeeding { enabled, reply: tx })
1520            .await
1521            .map_err(|_| crate::Error::Shutdown)?;
1522        rx.await.map_err(|_| crate::Error::Shutdown)
1523    }
1524
1525    /// Query whether BEP 16 super seeding mode is enabled.
1526    ///
1527    /// # Errors
1528    ///
1529    /// Returns an error if the session is shut down.
1530    pub async fn is_super_seeding(&self) -> crate::Result<bool> {
1531        let (tx, rx) = oneshot::channel();
1532        self.cmd_tx
1533            .send(TorrentCommand::IsSuperSeeding { reply: tx })
1534            .await
1535            .map_err(|_| crate::Error::Shutdown)?;
1536        rx.await.map_err(|_| crate::Error::Shutdown)
1537    }
1538
1539    /// Enable or disable user-requested seed-only mode (M159).
1540    ///
1541    /// When `enabled` is `true`, the actor stops scheduling new block requests
1542    /// and cancels all in-flight requests, but keeps existing peers connected
1543    /// and continues serving uploads. Toggling back to `false` restores normal
1544    /// piece scheduling.
1545    ///
1546    /// # Errors
1547    ///
1548    /// Returns [`crate::Error::Shutdown`] if the torrent actor has terminated.
1549    pub async fn set_seed_mode(&self, enabled: bool) -> crate::Result<()> {
1550        let (tx, rx) = oneshot::channel();
1551        self.cmd_tx
1552            .send(TorrentCommand::SetSeedMode { enabled, reply: tx })
1553            .await
1554            .map_err(|_| crate::Error::Shutdown)?;
1555        rx.await.map_err(|_| crate::Error::Shutdown)
1556    }
1557
1558    /// Add a new tracker URL to this torrent (fire-and-forget).
1559    ///
1560    /// The URL is validated and deduplicated by the tracker manager.
1561    ///
1562    /// # Errors
1563    ///
1564    /// Returns an error if the session is shut down.
1565    pub async fn add_tracker(&self, url: String) -> crate::Result<()> {
1566        self.cmd_tx
1567            .send(TorrentCommand::AddTracker { url })
1568            .await
1569            .map_err(|_| crate::Error::Shutdown)
1570    }
1571
1572    /// Replace all tracker URLs for this torrent.
1573    ///
1574    /// # Errors
1575    ///
1576    /// Returns an error if the session is shut down.
1577    pub async fn replace_trackers(&self, urls: Vec<String>) -> crate::Result<()> {
1578        let (tx, rx) = oneshot::channel();
1579        self.cmd_tx
1580            .send(TorrentCommand::ReplaceTrackers { urls, reply: tx })
1581            .await
1582            .map_err(|_| crate::Error::Shutdown)?;
1583        rx.await.map_err(|_| crate::Error::Shutdown)
1584    }
1585
1586    /// Trigger a full piece verification (force recheck).
1587    ///
1588    /// Transitions the torrent through `Checking` state, clears all piece
1589    /// completion data, re-verifies every piece against its hash, then
1590    /// transitions to `Seeding` (all valid) or `Downloading` (some missing).
1591    /// Returns after the check is complete.
1592    ///
1593    /// # Errors
1594    ///
1595    /// Returns an error if the session is shut down.
1596    pub async fn force_recheck(&self) -> crate::Result<()> {
1597        let (tx, rx) = oneshot::channel();
1598        self.cmd_tx
1599            .send(TorrentCommand::ForceRecheck { reply: tx })
1600            .await
1601            .map_err(|_| crate::Error::Shutdown)?;
1602        rx.await.map_err(|_| crate::Error::Shutdown)?
1603    }
1604
1605    /// Rename a file within the torrent on disk.
1606    ///
1607    /// Changes the filename of the specified file (by index) to `new_name`.
1608    /// The file stays in the same directory; only the filename component changes.
1609    /// Fires a `FileRenamed` alert on success.
1610    ///
1611    /// # Errors
1612    ///
1613    /// Returns an error if the session is shut down.
1614    pub async fn rename_file(&self, file_index: usize, new_name: String) -> crate::Result<()> {
1615        let (tx, rx) = oneshot::channel();
1616        self.cmd_tx
1617            .send(TorrentCommand::RenameFile {
1618                file_index,
1619                new_name,
1620                reply: tx,
1621            })
1622            .await
1623            .map_err(|_| crate::Error::Shutdown)?;
1624        rx.await.map_err(|_| crate::Error::Shutdown)?
1625    }
1626
1627    /// Route an incoming SSL peer (TLS already completed) to this torrent (M42).
1628    pub(crate) async fn spawn_ssl_peer(
1629        &self,
1630        addr: SocketAddr,
1631        stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1632    ) -> crate::Result<()> {
1633        self.cmd_tx
1634            .send(TorrentCommand::SpawnSslPeer {
1635                addr,
1636                stream: crate::types::BoxedAsyncStream(Box::new(stream)),
1637            })
1638            .await
1639            .map_err(|_| crate::Error::Shutdown)
1640    }
1641
1642    /// Set the per-torrent maximum number of connections (0 = use global default).
1643    ///
1644    /// # Errors
1645    ///
1646    /// Returns an error if the connection or binding fails.
1647    pub async fn set_max_connections(&self, limit: usize) -> crate::Result<()> {
1648        let (tx, rx) = oneshot::channel();
1649        self.cmd_tx
1650            .send(TorrentCommand::SetMaxConnections { limit, reply: tx })
1651            .await
1652            .map_err(|_| crate::Error::Shutdown)?;
1653        rx.await.map_err(|_| crate::Error::Shutdown)
1654    }
1655
1656    /// Get the current per-torrent maximum connection limit (0 = use global default).
1657    ///
1658    /// # Errors
1659    ///
1660    /// Returns an error if the connection or binding fails.
1661    pub async fn max_connections(&self) -> crate::Result<usize> {
1662        let (tx, rx) = oneshot::channel();
1663        self.cmd_tx
1664            .send(TorrentCommand::MaxConnections { reply: tx })
1665            .await
1666            .map_err(|_| crate::Error::Shutdown)?;
1667        rx.await.map_err(|_| crate::Error::Shutdown)
1668    }
1669
1670    /// Set the per-torrent maximum number of upload slots (unchoke slots).
1671    ///
1672    /// # Errors
1673    ///
1674    /// Returns an error if the data cannot be parsed or I/O fails.
1675    pub async fn set_max_uploads(&self, limit: usize) -> crate::Result<()> {
1676        let (tx, rx) = oneshot::channel();
1677        self.cmd_tx
1678            .send(TorrentCommand::SetMaxUploads { limit, reply: tx })
1679            .await
1680            .map_err(|_| crate::Error::Shutdown)?;
1681        rx.await.map_err(|_| crate::Error::Shutdown)
1682    }
1683
1684    /// Get the current per-torrent maximum upload slots (unchoke slots).
1685    ///
1686    /// # Errors
1687    ///
1688    /// Returns an error if the data cannot be parsed or I/O fails.
1689    pub async fn max_uploads(&self) -> crate::Result<usize> {
1690        let (tx, rx) = oneshot::channel();
1691        self.cmd_tx
1692            .send(TorrentCommand::MaxUploads { reply: tx })
1693            .await
1694            .map_err(|_| crate::Error::Shutdown)?;
1695        rx.await.map_err(|_| crate::Error::Shutdown)
1696    }
1697
1698    /// Get per-peer details for all connected peers.
1699    ///
1700    /// # Errors
1701    ///
1702    /// Returns an error if the session is shut down.
1703    pub async fn get_peer_info(&self) -> crate::Result<Vec<PeerInfo>> {
1704        let (tx, rx) = oneshot::channel();
1705        self.cmd_tx
1706            .send(TorrentCommand::GetPeerInfo { reply: tx })
1707            .await
1708            .map_err(|_| crate::Error::Shutdown)?;
1709        rx.await.map_err(|_| crate::Error::Shutdown)
1710    }
1711
1712    /// Get in-flight piece download status (the download queue).
1713    ///
1714    /// # Errors
1715    ///
1716    /// Returns an error if the data cannot be parsed or I/O fails.
1717    pub async fn get_download_queue(&self) -> crate::Result<Vec<PartialPieceInfo>> {
1718        let (tx, rx) = oneshot::channel();
1719        self.cmd_tx
1720            .send(TorrentCommand::GetDownloadQueue { reply: tx })
1721            .await
1722            .map_err(|_| crate::Error::Shutdown)?;
1723        rx.await.map_err(|_| crate::Error::Shutdown)
1724    }
1725
1726    /// Check whether a specific piece has been downloaded.
1727    ///
1728    /// # Errors
1729    ///
1730    /// Returns an error if the session is shut down.
1731    pub async fn have_piece(&self, index: u32) -> crate::Result<bool> {
1732        let (tx, rx) = oneshot::channel();
1733        self.cmd_tx
1734            .send(TorrentCommand::HavePiece { index, reply: tx })
1735            .await
1736            .map_err(|_| crate::Error::Shutdown)?;
1737        rx.await.map_err(|_| crate::Error::Shutdown)
1738    }
1739
1740    /// Get per-piece availability counts from connected peers.
1741    ///
1742    /// # Errors
1743    ///
1744    /// Returns an error if the session is shut down.
1745    pub async fn piece_availability(&self) -> crate::Result<Vec<u32>> {
1746        let (tx, rx) = oneshot::channel();
1747        self.cmd_tx
1748            .send(TorrentCommand::PieceAvailability { reply: tx })
1749            .await
1750            .map_err(|_| crate::Error::Shutdown)?;
1751        rx.await.map_err(|_| crate::Error::Shutdown)
1752    }
1753
1754    /// Get per-file bytes-downloaded progress.
1755    ///
1756    /// # Errors
1757    ///
1758    /// Returns an error if the session is shut down.
1759    pub async fn file_progress(&self) -> crate::Result<Vec<u64>> {
1760        let (tx, rx) = oneshot::channel();
1761        self.cmd_tx
1762            .send(TorrentCommand::FileProgress { reply: tx })
1763            .await
1764            .map_err(|_| crate::Error::Shutdown)?;
1765        rx.await.map_err(|_| crate::Error::Shutdown)
1766    }
1767
1768    /// Get the torrent's identity hashes (v1 and/or v2).
1769    ///
1770    /// # Errors
1771    ///
1772    /// Returns an error if the session is shut down.
1773    pub async fn info_hashes(&self) -> crate::Result<irontide_core::InfoHashes> {
1774        let (tx, rx) = oneshot::channel();
1775        self.cmd_tx
1776            .send(TorrentCommand::InfoHashes { reply: tx })
1777            .await
1778            .map_err(|_| crate::Error::Shutdown)?;
1779        rx.await.map_err(|_| crate::Error::Shutdown)
1780    }
1781
1782    /// Get the full v1 metainfo, if available.
1783    ///
1784    /// Returns `None` for magnet links before metadata has been received.
1785    ///
1786    /// # Errors
1787    ///
1788    /// Returns an error if the session is shut down.
1789    pub async fn torrent_file(&self) -> crate::Result<Option<TorrentMetaV1>> {
1790        let (tx, rx) = oneshot::channel();
1791        self.cmd_tx
1792            .send(TorrentCommand::TorrentFile { reply: tx })
1793            .await
1794            .map_err(|_| crate::Error::Shutdown)?;
1795        rx.await.map_err(|_| crate::Error::Shutdown)
1796    }
1797
1798    /// Get the full v2 metainfo, if available.
1799    ///
1800    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
1801    /// links before metadata has been received.
1802    ///
1803    /// # Errors
1804    ///
1805    /// Returns an error if the session is shut down.
1806    pub async fn torrent_file_v2(&self) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
1807        let (tx, rx) = oneshot::channel();
1808        self.cmd_tx
1809            .send(TorrentCommand::TorrentFileV2 { reply: tx })
1810            .await
1811            .map_err(|_| crate::Error::Shutdown)?;
1812        rx.await.map_err(|_| crate::Error::Shutdown)
1813    }
1814
1815    /// Force an immediate DHT announce for this torrent.
1816    ///
1817    /// Fire-and-forget at the torrent level — the DHT announce is best-effort.
1818    ///
1819    /// # Errors
1820    ///
1821    /// Returns an error if the session is shut down.
1822    pub async fn force_dht_announce(&self) -> crate::Result<()> {
1823        self.cmd_tx
1824            .send(TorrentCommand::ForceDhtAnnounce)
1825            .await
1826            .map_err(|_| crate::Error::Shutdown)
1827    }
1828
1829    /// Read all data for a specific piece from disk.
1830    ///
1831    /// Returns the complete piece data as `Bytes`. The piece must have been
1832    /// downloaded already; use [`have_piece`](Self::have_piece) to check first.
1833    ///
1834    /// # Errors
1835    ///
1836    /// Returns an error if the data cannot be parsed or I/O fails.
1837    pub async fn read_piece(&self, index: u32) -> crate::Result<Bytes> {
1838        let (tx, rx) = oneshot::channel();
1839        self.cmd_tx
1840            .send(TorrentCommand::ReadPiece { index, reply: tx })
1841            .await
1842            .map_err(|_| crate::Error::Shutdown)?;
1843        rx.await.map_err(|_| crate::Error::Shutdown)?
1844    }
1845
1846    /// Flush the disk write cache, ensuring all buffered writes are persisted.
1847    ///
1848    /// # Errors
1849    ///
1850    /// Returns an error if the session is shut down.
1851    pub async fn flush_cache(&self) -> crate::Result<()> {
1852        let (tx, rx) = oneshot::channel();
1853        self.cmd_tx
1854            .send(TorrentCommand::FlushCache { reply: tx })
1855            .await
1856            .map_err(|_| crate::Error::Shutdown)?;
1857        rx.await.map_err(|_| crate::Error::Shutdown)?
1858    }
1859
1860    /// Check whether this handle refers to a live torrent.
1861    ///
1862    /// Returns `false` after the torrent has been removed or shut down.
1863    /// This is a synchronous check on the channel state — no command dispatch.
1864    #[must_use]
1865    pub fn is_valid(&self) -> bool {
1866        !self.cmd_tx.is_closed()
1867    }
1868
1869    /// Clear any error state on the torrent and resume if it was paused due to error.
1870    ///
1871    /// # Errors
1872    ///
1873    /// Returns an error if the session is shut down.
1874    pub async fn clear_error(&self) -> crate::Result<()> {
1875        self.cmd_tx
1876            .send(TorrentCommand::ClearError)
1877            .await
1878            .map_err(|_| crate::Error::Shutdown)
1879    }
1880
1881    /// Get per-file open/mode status based on the current torrent state.
1882    ///
1883    /// Returns one [`crate::types::FileStatus`] entry per file in the torrent.
1884    ///
1885    /// # Errors
1886    ///
1887    /// Returns an error if the session is shut down.
1888    pub async fn file_status(&self) -> crate::Result<Vec<crate::types::FileStatus>> {
1889        let (tx, rx) = oneshot::channel();
1890        self.cmd_tx
1891            .send(TorrentCommand::FileStatus { reply: tx })
1892            .await
1893            .map_err(|_| crate::Error::Shutdown)?;
1894        rx.await.map_err(|_| crate::Error::Shutdown)
1895    }
1896
1897    /// Read the current torrent state as a [`TorrentFlags`] bitflag set.
1898    ///
1899    /// # Errors
1900    ///
1901    /// Returns an error if the session is shut down.
1902    pub async fn flags(&self) -> crate::Result<crate::types::TorrentFlags> {
1903        let (tx, rx) = oneshot::channel();
1904        self.cmd_tx
1905            .send(TorrentCommand::Flags { reply: tx })
1906            .await
1907            .map_err(|_| crate::Error::Shutdown)?;
1908        rx.await.map_err(|_| crate::Error::Shutdown)
1909    }
1910
1911    /// Set (enable) the specified torrent flags.
1912    ///
1913    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1914    ///
1915    /// # Errors
1916    ///
1917    /// Returns an error if the session is shut down.
1918    pub async fn set_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1919        let (tx, rx) = oneshot::channel();
1920        self.cmd_tx
1921            .send(TorrentCommand::SetFlags { flags, reply: tx })
1922            .await
1923            .map_err(|_| crate::Error::Shutdown)?;
1924        rx.await.map_err(|_| crate::Error::Shutdown)
1925    }
1926
1927    /// Unset (disable) the specified torrent flags.
1928    ///
1929    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1930    ///
1931    /// # Errors
1932    ///
1933    /// Returns an error if the session is shut down.
1934    pub async fn unset_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1935        let (tx, rx) = oneshot::channel();
1936        self.cmd_tx
1937            .send(TorrentCommand::UnsetFlags { flags, reply: tx })
1938            .await
1939            .map_err(|_| crate::Error::Shutdown)?;
1940        rx.await.map_err(|_| crate::Error::Shutdown)
1941    }
1942
1943    /// Immediately initiate a peer connection to the given address.
1944    ///
1945    /// Bypasses the normal peer selection queue — the connection attempt starts
1946    /// right away. Fire-and-forget: no reply is sent.
1947    ///
1948    /// # Errors
1949    ///
1950    /// Returns an error if the connection or binding fails.
1951    pub async fn connect_peer(&self, addr: SocketAddr) -> crate::Result<()> {
1952        self.cmd_tx
1953            .send(TorrentCommand::ConnectPeer { addr })
1954            .await
1955            .map_err(|_| crate::Error::Shutdown)
1956    }
1957
1958    /// M147: Send pre-resolved metadata from the background resolver.
1959    ///
1960    /// Fire-and-forget: uses `try_send` to avoid blocking the resolver task.
1961    /// If the channel is full or closed, the pre-resolved metadata is silently
1962    /// discarded (the `TorrentActor`'s own `FetchingMetadata` phase will handle it).
1963    pub(crate) fn send_pre_resolved_metadata(&self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
1964        let _ = self
1965            .cmd_tx
1966            .try_send(TorrentCommand::PreResolvedMetadata { info_bytes, peers });
1967    }
1968
1969    /// **TEST-ONLY (v0.173.2).** Synchronous counterpart to
1970    /// [`Self::send_pre_resolved_metadata`] that waits for the actor to
1971    /// finish processing before returning.
1972    ///
1973    /// Uses backpressured `cmd_tx.send().await` plus a oneshot completion
1974    /// ack, so `test_inject_metadata(...).await` resolves only after the
1975    /// metadata has been processed by the `TorrentActor`. See
1976    /// `TorrentCommand::TestInjectMetadata` for the rationale.
1977    ///
1978    /// # Errors
1979    /// - [`crate::Error::Shutdown`] if the torrent command channel is closed.
1980    #[cfg(feature = "test-util")]
1981    pub(crate) async fn test_inject_metadata(&self, info_bytes: Vec<u8>) -> crate::Result<()> {
1982        let (tx, rx) = tokio::sync::oneshot::channel();
1983        self.cmd_tx
1984            .send(TorrentCommand::TestInjectMetadata {
1985                info_bytes,
1986                reply: tx,
1987            })
1988            .await
1989            .map_err(|_| crate::Error::Shutdown)?;
1990        rx.await.map_err(|_| crate::Error::Shutdown)?;
1991        Ok(())
1992    }
1993}
1994
1995// ---------------------------------------------------------------------------
1996// M116: Cached file metadata for zero-allocation piece completion checks
1997// ---------------------------------------------------------------------------
1998
1999/// Pre-computed file metadata for zero-allocation piece completion checks.
2000#[derive(Debug, Clone)]
2001pub(crate) struct CachedFileEntry {
2002    pub(crate) index: usize,
2003    #[allow(dead_code)] // Used in tests; retained for future diagnostics
2004    pub(crate) length: u64,
2005    pub(crate) first_piece: u32,
2006    pub(crate) last_piece: u32,
2007}
2008
2009/// Cached file-to-piece mapping, computed once at torrent registration.
2010#[derive(Debug, Clone)]
2011pub(crate) struct CachedFileInfo {
2012    pub(crate) entries: Vec<CachedFileEntry>,
2013}
2014
2015pub(crate) fn build_cached_file_info(meta: &TorrentMetaV1, lengths: &Lengths) -> CachedFileInfo {
2016    let piece_length = lengths.piece_length();
2017    let files = meta.info.files();
2018    let mut entries = Vec::with_capacity(files.len());
2019    let mut offset = 0u64;
2020    for (index, file) in files.iter().enumerate() {
2021        let first_piece = (offset / piece_length) as u32;
2022        let last_piece = if file.length == 0 {
2023            first_piece
2024        } else {
2025            ((offset + file.length - 1) / piece_length) as u32
2026        };
2027        entries.push(CachedFileEntry {
2028            index,
2029            length: file.length,
2030            first_piece,
2031            last_piece,
2032        });
2033        offset += file.length;
2034    }
2035    CachedFileInfo { entries }
2036}
2037
2038// ---------------------------------------------------------------------------
2039// TorrentActor — internal single-owner event loop
2040// ---------------------------------------------------------------------------
2041
2042pub(crate) struct TorrentActor {
2043    pub(crate) config: TorrentConfig,
2044    /// M120: Lock timing settings for hot-path diagnostics.
2045    pub(crate) lock_timing: crate::timed_lock::LockTimingSettings,
2046    pub(crate) info_hash: Id20,
2047    pub(crate) our_peer_id: Id20,
2048    pub(crate) state: TorrentState,
2049
2050    // Disk I/O (None in magnet mode until metadata arrives)
2051    pub(crate) disk: Option<DiskHandle>,
2052    pub(crate) disk_manager: DiskManagerHandle,
2053    pub(crate) chunk_tracker: Option<ChunkTracker>,
2054    pub(crate) lengths: Option<Lengths>,
2055    pub(crate) num_pieces: u32,
2056
2057    // Piece management
2058    pub(crate) file_priorities: Vec<FilePriority>,
2059    pub(crate) wanted_pieces: Bitfield,
2060    pub(crate) end_game: EndGame,
2061
2062    // Streaming (M28)
2063    pub(crate) streaming_pieces: BTreeSet<u32>,
2064    pub(crate) time_critical_pieces: BTreeSet<u32>,
2065    pub(crate) streaming_cursors: Vec<crate::streaming::StreamingCursor>,
2066    pub(crate) piece_ready_tx: broadcast::Sender<u32>,
2067    pub(crate) have_watch_tx: tokio::sync::watch::Sender<Bitfield>,
2068    pub(crate) have_watch_rx: tokio::sync::watch::Receiver<Bitfield>,
2069    pub(crate) stream_read_semaphore: Arc<tokio::sync::Semaphore>,
2070
2071    // Peer management
2072    pub(crate) peers: HashMap<SocketAddr, PeerState>,
2073    /// Per-(SocketAddr × torrent) cumulative time we had each peer
2074    /// unchoked. Survives reconnects: when a `PeerState` is dropped on
2075    /// disconnect, its `unchoke_duration_total` is flushed into this map
2076    /// keyed by the peer's `SocketAddr`. Reads via
2077    /// `SessionHandle::peer_unchoke_durations` sum the persistent value
2078    /// here with each currently-live `PeerState`'s in-flight accumulator.
2079    /// Used by the libtorrent-mirror `optimistic_unchoke_fairness` perf
2080    /// scenario to assert the choker rotates upload slots fairly.
2081    pub(crate) unchoke_durations: HashMap<SocketAddr, Duration>,
2082    /// Cached peer download rates for piece stealing decisions.
2083    /// Refreshed on each periodic tick (~1s) instead of rebuilding per block.
2084    pub(crate) cached_peer_rates: FxHashMap<SocketAddr, f64>,
2085    /// Notify handle for reactive queue refill (legacy, unused in M73).
2086    #[allow(dead_code)]
2087    pub(crate) refill_notify: Arc<tokio::sync::Notify>,
2088    /// M93: Lock-free piece states (shared with peers via Arc).
2089    pub(crate) atomic_states: Option<Arc<crate::piece_reservation::AtomicPieceStates>>,
2090    /// M103: Shared block-level request/received bitmaps.
2091    pub(crate) block_maps: Option<Arc<BlockMaps>>,
2092    /// M103: Shared queue of stealable pieces.
2093    pub(crate) steal_candidates: Option<Arc<StealCandidates>>,
2094    /// M132: Last time we populated the steal queue with in-flight pieces.
2095    pub(crate) last_steal_populate: Instant,
2096    /// M120: Per-piece write guards to prevent steal/write races.
2097    pub(crate) piece_write_guards: Option<Arc<crate::piece_reservation::PieceWriteGuards>>,
2098    /// v0.173.3: Reusable buffer for `soft_reap_candidates` output.
2099    /// Reaped every `soft_reap_interval` tick (~1/s) with typical size
2100    /// 0-16 entries; held here so the allocation is reused across ticks.
2101    pub(crate) soft_reap_buf: Vec<std::net::SocketAddr>,
2102    /// v0.187.3 / 3A: sliding-window of recent proactive eviction timestamps.
2103    /// Entries older than 60s are pruned on each tick; the actor refuses to
2104    /// evict when the post-prune length is >= `proactive_evictions_per_minute_limit`.
2105    /// Prevents the 130 → 20-50 churn observed in the dogfood report.
2106    pub(crate) eviction_history: std::collections::VecDeque<std::time::Instant>,
2107    /// v0.187.3 / Bug 8a: when set, the main loop runs the choker on the
2108    /// next iteration regardless of `unchoke_interval`. Set on transition
2109    /// INTO `Seeding` so interested peers see Unchoke promptly. Cleared
2110    /// after the next `run_choker().await`.
2111    pub(crate) force_immediate_choker_tick: bool,
2112    /// M187: Actor-owned piece dispatch tracker (direct-acquire model).
2113    pub(crate) piece_tracker: Option<PieceTracker>,
2114    /// M187: Watch sender that broadcasts `PieceOrderMap` to peer tasks.
2115    pub(crate) order_map_tx: tokio::sync::watch::Sender<Arc<PieceOrderMap>>,
2116    /// M93: Maps piece index -> peer slab slot that owns it.
2117    pub(crate) piece_owner: Vec<Option<u16>>,
2118    /// M93: Arena-allocated peer tracking: slot <-> `SocketAddr`.
2119    pub(crate) peer_slab: crate::piece_reservation::PeerSlab,
2120    #[allow(dead_code)]
2121    pub(crate) priority_pieces: BTreeSet<u32>,
2122    /// M93: Maximum in-flight pieces.
2123    pub(crate) max_in_flight: usize,
2124    /// Piece notify handle (for driver spawning).
2125    pub(crate) reservation_notify: Option<Arc<tokio::sync::Notify>>,
2126    /// Dispatch state snapshot at the previous pipeline tick:
2127    /// `(queue_pieces.count_ones(), inflight.len())`. The tick uses this to
2128    /// gate its `notify_waiters()` safety-net call — if neither value has
2129    /// changed, no peer needs waking, and waking all of them would just
2130    /// trigger spurious acquire calls (the 93% `NoneAvailable` rate measured
2131    /// on 2026-05-11 was largely driven by unconditional 1 Hz wake spam).
2132    /// `None` until the first tick records a baseline.
2133    pub(crate) last_tick_dispatch_state: Option<(u32, usize)>,
2134    pub(crate) choker: Choker,
2135    /// M159: User-requested seed-only mode flag.
2136    ///
2137    /// When `true`, the actor stops issuing new block requests (gating the
2138    /// `StartRequesting` dispatch sent to peer tasks) and cancels any
2139    /// in-flight requests. Uploads continue unaffected. Distinct from the
2140    /// naturally-complete seeding state tracked by `state == Seeding`.
2141    pub(crate) user_seed_mode: bool,
2142    /// Whether the user force-started this torrent (bypassing queue limits).
2143    pub(crate) user_forced: bool,
2144    /// Per-torrent connection limit override (0 = use `config.max_peers`).
2145    pub(crate) max_connections: usize,
2146    /// M137: Unified peer lifecycle tracker (replaces `peers_connected` + `connect_backoff` + `peer_tx` + `unique_peers_attempted`).
2147    pub(crate) peer_states: Option<Arc<crate::peer_states::PeerStates>>,
2148    /// M147: `ConnectPool` semaphore — gates connection attempts only.
2149    /// Permits are released on `HandshakeComplete` (not held for peer lifetime).
2150    pub(crate) connect_semaphore: Arc<tokio::sync::Semaphore>,
2151    /// M147: Maps peer address → permit holder. Permits are taken on `HandshakeComplete`
2152    /// to free `ConnectPool` slots. RAII cleanup on task exit handles failure cases.
2153    pub(crate) connect_permits:
2154        HashMap<SocketAddr, Arc<parking_lot::Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>>,
2155    /// M107: Receiver for connect requests from the adder task.
2156    pub(crate) connect_rx: Option<mpsc::Receiver<ConnectPeer>>,
2157
2158    // Metadata (for magnet links)
2159    pub(crate) metadata_downloader: Option<MetadataDownloader>,
2160
2161    // Parsed torrent meta (for piece hash verification)
2162    pub(crate) meta: Option<TorrentMetaV1>,
2163
2164    /// M116: Pre-computed file->piece mapping for zero-alloc completion checks.
2165    pub(crate) cached_files: Option<CachedFileInfo>,
2166
2167    // Stats
2168    pub(crate) downloaded: u64,
2169    pub(crate) uploaded: u64,
2170    pub(crate) checking_progress: f32,
2171    pub(crate) total_download: u64,
2172    pub(crate) total_upload: u64,
2173    pub(crate) total_failed_bytes: u64,
2174    pub(crate) total_redundant_bytes: u64,
2175    pub(crate) added_time: i64,
2176    pub(crate) completed_time: i64,
2177    pub(crate) last_download: i64,
2178    pub(crate) last_upload: i64,
2179    pub(crate) last_seen_complete: i64,
2180    pub(crate) active_duration: i64,
2181    pub(crate) finished_duration: i64,
2182    pub(crate) seeding_duration: i64,
2183    pub(crate) active_since: Option<std::time::Instant>,
2184    pub(crate) state_duration_since: Option<std::time::Instant>,
2185    #[allow(dead_code)] // M104: ConnectPhase removed; kept for future diagnostics
2186    pub(crate) started_at: std::time::Instant,
2187    pub(crate) moving_storage: bool,
2188    pub(crate) has_incoming: bool,
2189    pub(crate) need_save_resume: bool,
2190    pub(crate) error: String,
2191    pub(crate) error_file: i32,
2192
2193    // Channels
2194    pub(crate) cmd_rx: mpsc::Receiver<TorrentCommand>,
2195    pub(crate) event_tx: mpsc::Sender<PeerEvent>,
2196    pub(crate) event_rx: mpsc::Receiver<PeerEvent>,
2197
2198    // Async disk pipeline channels
2199    pub(crate) write_error_rx: mpsc::Receiver<crate::disk::DiskWriteError>,
2200    pub(crate) write_error_tx: mpsc::Sender<crate::disk::DiskWriteError>,
2201    pub(crate) verify_result_rx: mpsc::Receiver<crate::disk::VerifyResult>,
2202    pub(crate) verify_result_tx: mpsc::Sender<crate::disk::VerifyResult>,
2203    /// Pieces currently awaiting async verification — prevents duplicate
2204    /// verify tasks when end game or slow peers deliver duplicate blocks.
2205    pub(crate) pending_verify: HashSet<u32>,
2206    /// Generation counter per piece — increments on release/re-reserve.
2207    /// Used to detect stale hash results from the `HashPool` (M96).
2208    pub(crate) piece_generations: Vec<u64>,
2209    /// Receiver for hash pool results (M96).
2210    pub(crate) hash_result_rx: tokio::sync::mpsc::Receiver<crate::hash_pool::HashResult>,
2211    /// Sender for hash pool results — cloned into `DiskHandle` (M96).
2212    pub(crate) hash_result_tx: tokio::sync::mpsc::Sender<crate::hash_pool::HashResult>,
2213
2214    // TCP listener for incoming peer connections
2215    pub(crate) listener: Option<Box<dyn crate::transport::TransportListener>>,
2216
2217    // uTP socket for outbound connections (shared with session, cloned)
2218    pub(crate) utp_socket: Option<irontide_utp::UtpSocket>,
2219    // IPv6 uTP socket for outbound connections to IPv6 peers
2220    pub(crate) utp_socket_v6: Option<irontide_utp::UtpSocket>,
2221
2222    // Tracker management
2223    pub(crate) tracker_manager: TrackerManager,
2224    /// M143: Receiver for streaming tracker announce results.
2225    /// `Some` while a background announce is in-flight, `None` when idle.
2226    pub(crate) tracker_result_rx: Option<mpsc::Receiver<crate::tracker_manager::TrackerPeerBatch>>,
2227
2228    // DHT handles (shared, optional). M173 Lane B (B6): subscribe to
2229    // the session-level DhtBroadcast so a runtime DHT restart is
2230    // observed on the next call without holding a stale clone. The
2231    // `dht_enabled` flag gates BEP 27 private torrents (which must
2232    // not announce to DHT regardless of session-level enable) plus
2233    // any per-torrent opt-out.
2234    pub(crate) dht_rx: irontide_dht::DhtReceiver,
2235    pub(crate) dht_v6_rx: irontide_dht::DhtReceiver,
2236    pub(crate) dht_enabled: bool,
2237    pub(crate) dht_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2238    pub(crate) dht_v6_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2239    /// Consecutive times the V6 DHT returned an empty table.
2240    /// After 30 failures (~3s at 100ms), stop retrying to avoid log spam.
2241    pub(crate) dht_v6_empty_count: u32,
2242    /// Timestamp of last V6 DHT retry attempt (M97).
2243    pub(crate) dht_v6_last_retry: Option<std::time::Instant>,
2244
2245    // Alert system (M15)
2246    pub(crate) alert_tx: broadcast::Sender<Alert>,
2247    pub(crate) alert_mask: Arc<AtomicU32>,
2248
2249    // Rate limiting (M14)
2250    pub(crate) upload_bucket: crate::rate_limiter::TokenBucket,
2251    pub(crate) download_bucket: SharedBucket,
2252    pub(crate) global_upload_bucket: Option<SharedBucket>,
2253    #[allow(dead_code)] // M73: rate limiting deferred to M74
2254    pub(crate) global_download_bucket: Option<SharedBucket>,
2255    pub(crate) slot_tuner: crate::slot_tuner::SlotTuner,
2256    pub(crate) upload_bytes_interval: u64,
2257
2258    /// Peak aggregate download rate observed (bytes/sec), for peer turnover cutoff.
2259    pub(crate) peak_download_rate: u64,
2260
2261    // Web seeding (M22)
2262    pub(crate) web_seeds: HashMap<String, mpsc::Sender<crate::web_seed::WebSeedCommand>>,
2263    pub(crate) banned_web_seeds: HashSet<String>,
2264    pub(crate) web_seed_in_flight: HashMap<u32, String>,
2265    /// M178: Per-URL stats accumulated from `PeerEvent::WebSeedProgress`
2266    /// events emitted by `WebSeedTask`. Persisted to fast-resume so stats
2267    /// survive app restart (see `resume_file.rs`).
2268    pub(crate) web_seed_stats: HashMap<String, irontide_core::WebSeedStats>,
2269    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2270    /// via PEX (BEP 11) since this actor started. Surfaced through
2271    /// `SessionHandle::pex_peer_count` for the qBt v2 trackers endpoint
2272    /// and the GUI Trackers tab pseudo-tracker rows. Reset on torrent
2273    /// removal (actor lifecycle).
2274    pub(crate) pex_peer_count: usize,
2275    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2276    /// via LSD (BEP 14) multicast. Self-cookie filtering happens upstream
2277    /// in M174's session-level LSD path.
2278    pub(crate) lsd_peer_count: usize,
2279
2280    // BEP 16 super seeding (M23)
2281    pub(crate) super_seed: Option<crate::super_seed::SuperSeedState>,
2282    // M118: Broadcast channel for Have distribution (replaces HaveBuffer)
2283    pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender<u32>,
2284
2285    /// M44: pieces we've suggested to each peer (avoid re-suggesting)
2286    pub(crate) suggested_to_peers: HashMap<SocketAddr, HashSet<u32>>,
2287
2288    /// M44: pieces for which we've already sent predictive Have
2289    pub(crate) predictive_have_sent: HashSet<u32>,
2290
2291    // Smart banning (M25)
2292    pub(crate) ban_manager: crate::session::SharedBanManager,
2293    pub(crate) piece_contributors: HashMap<u32, HashSet<std::net::IpAddr>>,
2294    pub(crate) parole_pieces: HashMap<u32, crate::ban::ParoleState>,
2295
2296    // IP filtering (M29)
2297    pub(crate) ip_filter: crate::session::SharedIpFilter,
2298
2299    // BEP 40 peer priority (M32b)
2300    pub(crate) external_ip: Option<std::net::IpAddr>,
2301
2302    // Share mode (M32c): LRU tracker for in-memory piece relay.
2303    // Tracks which pieces are currently "live" (servable) in share mode.
2304    // Oldest pieces are evicted when capacity is reached.
2305    pub(crate) share_lru: std::collections::VecDeque<u32>,
2306    /// Max pieces to keep live in share mode (0 = share mode disabled).
2307    pub(crate) share_max_pieces: usize,
2308
2309    // Extension plugins (M32d)
2310    pub(crate) plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
2311
2312    // BEP 52 v2/hybrid support (M34-M35)
2313    pub(crate) hash_picker: Option<irontide_core::HashPicker>,
2314    pub(crate) version: irontide_core::TorrentVersion,
2315    #[allow(dead_code)] // stored for hybrid torrent re-serialization (M35 Task 5)
2316    pub(crate) meta_v2: Option<irontide_core::TorrentMetaV2>,
2317
2318    /// Full info hashes for dual-swarm support (v1 + v2 for hybrid).
2319    pub(crate) info_hashes: irontide_core::InfoHashes,
2320
2321    /// Dual-swarm DHT peer receivers (v2 hash in hybrid torrents).
2322    pub(crate) dht_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2323    pub(crate) dht_v6_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2324
2325    /// BEP 53: deferred file selection from magnet `so=` parameter.
2326    /// Applied after metadata is received to set file priorities.
2327    pub(crate) magnet_selected_files: Option<Vec<irontide_core::FileSelection>>,
2328
2329    /// I2P SAM session for anonymous peer connections (M41).
2330    pub(crate) sam_session: Option<Arc<crate::i2p::SamSession>>,
2331
2332    /// Receiver for incoming I2P peer connections (M41).
2333    pub(crate) i2p_accept_rx: Option<mpsc::Receiver<crate::i2p::SamStream>>,
2334
2335    /// Counter for generating synthetic `SocketAddr` values for I2P peers (M41).
2336    pub(crate) i2p_peer_counter: u32,
2337
2338    /// Maps synthetic `SocketAddr` → `I2pDestination` for outbound I2P connects.
2339    pub(crate) i2p_destinations: HashMap<SocketAddr, crate::i2p::I2pDestination>,
2340
2341    /// SSL manager for SSL torrent certificate handling (M42).
2342    pub(crate) ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
2343
2344    /// Per-class rate limiting with mixed-mode (M45).
2345    pub(crate) rate_limiter_set: crate::rate_limiter::RateLimiterSet,
2346    /// Whether auto-sequential mode is currently active (hysteresis state).
2347    pub(crate) auto_sequential_active: bool,
2348    /// Network transport factory for TCP operations (M51).
2349    pub(crate) factory: Arc<crate::transport::NetworkFactory>,
2350    /// Shared hash pool for parallel piece verification (M96).
2351    pub(crate) hash_pool_ref: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
2352    /// M108: Shared snapshot of connected peer addresses for PEX send tasks.
2353    pub(crate) live_outgoing_peers:
2354        std::sync::Arc<parking_lot::RwLock<std::collections::HashMap<SocketAddr, u8>>>,
2355    /// M108: Total outbound connection attempts dispatched to peer adder.
2356    pub(crate) connect_attempts: u64,
2357    /// M108: Total connection failures (peers that disconnected).
2358    pub(crate) connect_failures: u64,
2359    /// M138: Total number of peers evicted by proactive choke rotation.
2360    pub(crate) choke_rotations: u64,
2361    /// M149: When each in-flight piece started downloading. Indexed by piece index.
2362    /// Set when `piece_owner` assigns a piece, cleared on verify/hash-fail.
2363    pub(crate) inflight_started: Vec<Option<Instant>>,
2364    /// M149: Rolling window of recent piece completion times for steal threshold.
2365    pub(crate) completed_piece_times: std::collections::VecDeque<Duration>,
2366    /// M149: Total number of piece-level steals performed.
2367    pub(crate) piece_steals: u64,
2368    /// M190: Total holepunch rendezvous requests we relayed.
2369    pub(crate) holepunch_relayed: u64,
2370    /// M190: Per-peer rate limit for holepunch rendezvous requests.
2371    pub(crate) holepunch_relay_rate: HashMap<SocketAddr, (Instant, u32)>,
2372    /// M112: Tracks recent holepunch attempts to prevent retry storms.
2373    pub(crate) holepunch_cooldowns: HashMap<SocketAddr, Instant>,
2374    /// M112: Buffer for holepunch attempts (`disconnect_peer` is sync, `try_holepunch` is async).
2375    pub(crate) holepunch_pending: Vec<SocketAddr>,
2376    /// Sim-perf engine surface: shared session counters used by
2377    /// `rebuild_availability_snapshot` to track Allow / Defer rates,
2378    /// and by per-peer spawn sites to seed `PeerShared::counters`.
2379    pub(crate) counters: Arc<crate::stats::SessionCounters>,
2380}
2381
2382/// Maximum number of in-flight end-game requests per peer.
2383/// libtorrent continues full pipelining in end-game; we use a moderate
2384/// depth so that round-trip latency doesn't bottleneck throughput.
2385/// End-game pipeline depth: match normal mode (128 slots per peer).
2386/// Safe because the reactive per-block cascade was replaced with a 200ms
2387/// batch refill tick — raising depth no longer amplifies picker invocations.
2388pub(crate) const END_GAME_DEPTH: usize = 128;
2389
2390/// Minimum free pipeline slots before invoking the full piece picker in
2391/// `handle_piece_data()`.  Avoids running the 5-layer picker on every single
2392impl TorrentActor {
2393    /// Returns the current IPv4 `DhtHandle`, or `None` if DHT is disabled
2394    /// for this torrent (BEP 27 private, per-torrent opt-out, or
2395    /// session-level disable). M173 Lane B (B6): reads from the
2396    /// session-level [`irontide_dht::DhtBroadcast`] receiver, so a
2397    /// runtime DHT restart (B11) is observed transparently here.
2398    pub(crate) fn current_dht(&self) -> Option<irontide_dht::DhtHandle> {
2399        if self.dht_enabled {
2400            self.dht_rx.current()
2401        } else {
2402            None
2403        }
2404    }
2405
2406    /// Returns the current IPv6 `DhtHandle`, or `None` if DHT is disabled.
2407    /// See [`Self::current_dht`].
2408    pub(crate) fn current_dht_v6(&self) -> Option<irontide_dht::DhtHandle> {
2409        if self.dht_enabled {
2410            self.dht_v6_rx.current()
2411        } else {
2412            None
2413        }
2414    }
2415
2416    /// Hold-window helper: wait up to `hold` for the IPv4 DHT broadcast
2417    /// to deliver a non-`None` handle. Returns `None` if the wait
2418    /// times out OR if DHT is disabled for this torrent.
2419    ///
2420    /// Used by call sites that issue requests during a DHT restart
2421    /// window and prefer to hold a brief moment rather than fail
2422    /// immediately. The hold is bounded — callers must not loop on
2423    /// `None`, since a permanently-disabled DHT will hit the timeout
2424    /// every iteration.
2425    ///
2426    /// # Errors
2427    ///
2428    /// Returns `None` on timeout, on disabled DHT, or if the
2429    /// broadcast sender has been dropped (session shutting down).
2430    #[allow(dead_code)] // wired by future per-call-site refactors as needed
2431    pub(crate) async fn current_dht_or_wait(
2432        &mut self,
2433        hold: std::time::Duration,
2434    ) -> Option<irontide_dht::DhtHandle> {
2435        if !self.dht_enabled {
2436            return None;
2437        }
2438        if let Some(handle) = self.dht_rx.current() {
2439            return Some(handle);
2440        }
2441        // Wait for the broadcast to fire `replace(Some(_))`.
2442        match tokio::time::timeout(hold, self.dht_rx.changed()).await {
2443            Ok(Ok(())) => self.dht_rx.current(),
2444            Ok(Err(_)) | Err(_) => None,
2445        }
2446    }
2447
2448    /// Main event loop.
2449    async fn run(mut self) {
2450        // Verify existing pieces on startup (resume support)
2451        self.verify_existing_pieces().await;
2452
2453        // M93: Initialize lock-free piece states after verification
2454        // so we_have reflects already-verified pieces.
2455        if let Some(ct) = &self.chunk_tracker {
2456            let atomic_states = Arc::new(AtomicPieceStates::new(
2457                self.num_pieces,
2458                ct.bitfield(),
2459                &self.wanted_pieces,
2460            ));
2461            self.atomic_states = Some(Arc::clone(&atomic_states));
2462            self.piece_owner = vec![None; self.num_pieces as usize];
2463            // M149: Initialize inflight tracking
2464            self.inflight_started = vec![None; self.num_pieces as usize];
2465            self.max_in_flight = self.config.max_in_flight_pieces;
2466
2467            // M103: Initialize block stealing infrastructure
2468            if self.config.use_block_stealing {
2469                if let Some(ref lengths) = self.lengths {
2470                    self.block_maps = Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
2471                }
2472                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
2473            }
2474            // M120: Per-piece write guards
2475            self.piece_write_guards = Some(Arc::new(
2476                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
2477            ));
2478
2479            // M187: Init direct-acquire dispatch state.
2480            self.piece_tracker = Some(PieceTracker::new(
2481                self.num_pieces,
2482                ct.bitfield(),
2483                &self.wanted_pieces,
2484            ));
2485            if let Some(ref cached) = self.cached_files {
2486                let file_piece_ranges: Vec<(u32, u32)> = cached
2487                    .entries
2488                    .iter()
2489                    .map(|e| (e.first_piece, e.last_piece))
2490                    .collect();
2491                let om = Arc::new(PieceOrderMap::build(
2492                    &self.file_priorities,
2493                    &file_piece_ranges,
2494                    self.num_pieces,
2495                    0,
2496                ));
2497                self.order_map_tx.send_replace(om);
2498            }
2499
2500            let notify = Arc::new(tokio::sync::Notify::new());
2501            self.reservation_notify = Some(notify);
2502        }
2503
2504        // Spawn web seeds if not already seeding
2505        if self.state != TorrentState::Seeding {
2506            self.spawn_web_seeds();
2507            self.assign_pieces_to_web_seeds();
2508        }
2509
2510        // M147: Set up ConnectPool — semaphore gates connection attempts only.
2511        // Permits are released on HandshakeComplete, not held for peer lifetime.
2512        let connect_semaphore = Arc::new(tokio::sync::Semaphore::new(
2513            self.effective_max_connections(),
2514        ));
2515        self.connect_semaphore = Arc::clone(&connect_semaphore);
2516        self.connect_permits.clear();
2517        // M137: Create PeerStates with the adder's input channel.
2518        // v0.187.3: pull eviction-ban cap + duration from session settings so
2519        // user changes via apply_settings take effect on the next spawn.
2520        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
2521        let peer_states = Arc::new(crate::peer_states::PeerStates::new_with_config(
2522            queue_tx,
2523            self.config.eviction_ban_set_cap,
2524            std::time::Duration::from_secs(self.config.eviction_ban_duration_secs),
2525        ));
2526        self.peer_states = Some(Arc::clone(&peer_states));
2527        let (adder_connect_tx, adder_connect_rx) = mpsc::channel(64);
2528        self.connect_rx = Some(adder_connect_rx);
2529        // M147: ConnectPool semaphore gates connection attempts (released on handshake)
2530        tokio::spawn(peer_adder::peer_adder_task(
2531            queue_rx,
2532            Arc::clone(&connect_semaphore),
2533            Arc::clone(&peer_states),
2534            Arc::clone(&self.ban_manager),
2535            Arc::clone(&self.ip_filter),
2536            adder_connect_tx,
2537        ));
2538
2539        let mut unchoke_interval = tokio::time::interval(Duration::from_secs(10));
2540        let mut rate_interval = tokio::time::interval(Duration::from_secs(2));
2541        rate_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2542        let mut optimistic_interval = tokio::time::interval(Duration::from_secs(30));
2543        let mut refill_interval = tokio::time::interval(Duration::from_millis(100));
2544        let mut dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::ZERO));
2545        let mut suggest_interval = if self.config.suggest_mode {
2546            Some(tokio::time::interval(Duration::from_secs(30)))
2547        } else {
2548            None
2549        };
2550        // M136: 1s steal-queue maintenance tick.
2551        let mut turnover_interval = tokio::time::interval(Duration::from_secs(1));
2552        let mut pipeline_tick_interval = tokio::time::interval(Duration::from_secs(1));
2553        // M77: Skip missed ticks — safety-net notify should fire at most once/second
2554        pipeline_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2555        let mut end_game_tick_interval = tokio::time::interval(Duration::from_millis(200));
2556        end_game_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2557        let mut diag_interval = tokio::time::interval(Duration::from_secs(5));
2558        // M108: 30s connection success rate summary for variance diagnosis.
2559        let mut conn_stats_interval = tokio::time::interval(Duration::from_secs(30));
2560        // M107: 5s metadata piece timeout — only meaningful in FetchingMetadata state
2561        let mut metadata_timeout_interval = tokio::time::interval(Duration::from_secs(5));
2562        // M103: 50ms debounce for reactive snapshot (was 500ms fixed interval)
2563        // M147: 1s soft reap interval — disconnects connecting peers without TCP SYN-ACK
2564        let mut soft_reap_interval = tokio::time::interval(Duration::from_secs(1));
2565        // M148: 2s proactive eviction — breaks the catch-22 where LivePool fills with
2566        // deadweight and no HandshakeComplete events arrive to trigger eviction.
2567        let mut eviction_interval = tokio::time::interval(Duration::from_secs(2));
2568        eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2569
2570        // Don't fire immediately for the first tick
2571        unchoke_interval.tick().await;
2572        optimistic_interval.tick().await;
2573        refill_interval.tick().await;
2574        // Note: dht_requery_sleep uses Sleep (not Interval), no initial tick skip needed
2575        if let Some(ref mut si) = suggest_interval {
2576            si.tick().await; // skip initial tick
2577        }
2578        turnover_interval.tick().await;
2579        pipeline_tick_interval.tick().await;
2580        end_game_tick_interval.tick().await;
2581        diag_interval.tick().await;
2582        conn_stats_interval.tick().await;
2583        metadata_timeout_interval.tick().await;
2584        soft_reap_interval.tick().await;
2585        eviction_interval.tick().await;
2586
2587        // Initial tracker announce (Started event) — non-blocking, fires via select! arm
2588        // DHT announce (v4 + v6) — dual-swarm for hybrid torrents
2589        if self.state == TorrentState::Downloading && self.config.enable_dht {
2590            // Primary hash (v1 or best_v1)
2591            if let Some(dht) = self.current_dht()
2592                && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
2593            {
2594                warn!("DHT v4 announce failed: {e}");
2595            }
2596            if let Some(dht6) = self.current_dht_v6()
2597                && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
2598            {
2599                debug!("DHT v6 announce failed: {e}");
2600            }
2601            // Dual-swarm: also announce v2 hash (truncated) for hybrid torrents
2602            if self.info_hashes.is_hybrid()
2603                && let Some(v2) = self.info_hashes.v2
2604            {
2605                let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
2606                if v2_as_v1 != self.info_hash {
2607                    if let Some(dht) = self.current_dht()
2608                        && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
2609                    {
2610                        debug!("DHT v4 dual-swarm announce failed: {e}");
2611                    }
2612                    if let Some(dht6) = self.current_dht_v6()
2613                        && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
2614                    {
2615                        debug!("DHT v6 dual-swarm announce failed: {e}");
2616                    }
2617                }
2618            }
2619        }
2620
2621        // I2P accept loop: spawn a background task that feeds incoming I2P
2622        // connections back via a channel, so the select! arm can handle them.
2623        if self.config.enable_i2p
2624            && let Some(ref sam) = self.sam_session
2625        {
2626            let (tx, rx) = mpsc::channel(16);
2627            let sam = Arc::clone(sam);
2628            tokio::spawn(async move {
2629                loop {
2630                    match sam.accept().await {
2631                        Ok(stream) => {
2632                            if tx.send(stream).await.is_err() {
2633                                break; // torrent actor dropped
2634                            }
2635                        }
2636                        Err(e) => {
2637                            warn!("I2P accept error: {e}");
2638                            tokio::time::sleep(Duration::from_secs(5)).await;
2639                        }
2640                    }
2641                }
2642            });
2643            self.i2p_accept_rx = Some(rx);
2644        }
2645
2646        loop {
2647            tokio::select! {
2648                biased;
2649                // Events from peers — batch-drain to reduce select! overhead.
2650                // At 100 MB/s we get ~6K events/sec; processing one-by-one
2651                // means 6K select! iterations with waker re-registration.
2652                // biased; ensures this high-throughput arm is checked first.
2653                event = self.event_rx.recv() => {
2654                    if let Some(event) = event {
2655                        // M182: ping the per-peer event_drain Notify so
2656                        // the reader's BackpressureQueue retries any
2657                        // spilled events. Looked up from peers by
2658                        // event.peer_addr (most variants carry one);
2659                        // events without a peer (PexPeers,
2660                        // TrackersReceived, WebSeed*) skip the ping —
2661                        // they don't fill the per-peer event channel.
2662                        Self::ping_event_drain(&self.peers, &event);
2663                        self.handle_peer_event(event)
2664                            .instrument(tracing::debug_span!("handle_peer_event"))
2665                            .await;
2666                        // Drain up to 512 more ready events without re-entering select!
2667                        for _ in 0..512 {
2668                            match self.event_rx.try_recv() {
2669                                Ok(event) => {
2670                                    Self::ping_event_drain(&self.peers, &event);
2671                                    self.handle_peer_event(event).await;
2672                                }
2673                                Err(_) => break,
2674                            }
2675                        }
2676                    }
2677                }
2678                // Async piece verification results
2679                Some(result) = self.verify_result_rx.recv() => {
2680                    self.pending_verify.remove(&result.piece);
2681                    // Guard: ignore stale/duplicate results for already-verified pieces
2682                    let dominated = self.chunk_tracker.as_ref()
2683                        .is_some_and(|ct| ct.bitfield().get(result.piece));
2684                    if !dominated {
2685                        if result.passed {
2686                            self.on_piece_verified(result.piece).await;
2687                        } else {
2688                            self.on_piece_hash_failed(result.piece).await;
2689                            // M73: Drivers pick up released pieces automatically via shared state
2690                        }
2691                    }
2692                }
2693                // M96: Hash pool verification results
2694                Some(result) = self.hash_result_rx.recv() => {
2695                    self.handle_hash_result(result).await;
2696                }
2697                // Commands from handle
2698                cmd = self.cmd_rx.recv() => {
2699                    match cmd {
2700                        Some(TorrentCommand::AddPeers { peers, source }) => {
2701                            self.handle_add_peers(peers, source);
2702                        }
2703                        Some(TorrentCommand::Stats { reply }) => {
2704                            let _ = reply.send(self.make_stats());
2705                        }
2706                        Some(TorrentCommand::Pause) => {
2707                            self.handle_pause().await;
2708                        }
2709                        Some(TorrentCommand::Queue) => {
2710                            self.handle_queue();
2711                        }
2712                        Some(TorrentCommand::Resume) => {
2713                            self.handle_resume().await;
2714                        }
2715                        Some(TorrentCommand::ForceResume) => {
2716                            self.user_forced = true;
2717                            self.handle_resume().await;
2718                        }
2719                        Some(TorrentCommand::SetCategory { category, reply }) => {
2720                            // M170: update the per-torrent category label.
2721                            // Marks resume as dirty so the next periodic
2722                            // save captures it.
2723                            self.config.category = category;
2724                            self.need_save_resume = true;
2725                            let _ = reply.send(());
2726                        }
2727                        Some(TorrentCommand::SetTags { tags, reply }) => {
2728                            // M171: replace the per-torrent tag set. Marks
2729                            // resume as dirty so the next periodic save
2730                            // captures it. `make_stats()` reads
2731                            // `self.config.tags` directly so the change is
2732                            // immediately visible to the next `stats()`
2733                            // call.
2734                            self.config.tags = tags;
2735                            self.need_save_resume = true;
2736                            let _ = reply.send(());
2737                        }
2738                        Some(TorrentCommand::GetWebSeeds { reply }) => {
2739                            // M171 Lane B: union of BEP 19 `url-list` and
2740                            // BEP 17 `httpseeds`. Order: BEP 19 first, then
2741                            // BEP 17 — matches the wire order. Returns an
2742                            // empty vec when metadata hasn't resolved yet
2743                            // (magnet still fetching info dict).
2744                            let urls = match &self.meta {
2745                                Some(meta) => {
2746                                    let mut v = Vec::with_capacity(
2747                                        meta.url_list.len() + meta.httpseeds.len(),
2748                                    );
2749                                    v.extend(meta.url_list.iter().cloned());
2750                                    v.extend(meta.httpseeds.iter().cloned());
2751                                    v
2752                                }
2753                                None => Vec::new(),
2754                            };
2755                            let _ = reply.send(urls);
2756                        }
2757                        Some(TorrentCommand::GetPieceStates { reply }) => {
2758                            // M171 Lane B: snapshot per-piece state as qBt
2759                            // codes. Returns an empty vec when metadata
2760                            // hasn't resolved (piece count unknown).
2761                            let states = match self.atomic_states.as_ref() {
2762                                Some(atomic) => atomic.snapshot(),
2763                                None => Vec::new(),
2764                            };
2765                            let _ = reply.send(states);
2766                        }
2767                        Some(TorrentCommand::GetPieceHashes { offset, limit, reply }) => {
2768                            // M171 Lane B: v1 piece hashes live in
2769                            // `meta.info.pieces` (20-byte SHA-1 concat);
2770                            // v2-only piece hashes live in
2771                            // `meta_v2.piece_layers` values (32-byte
2772                            // SHA-256 concat). Hybrid prefers v1 because
2773                            // the qBt client ecosystem treats v1 as the
2774                            // canonical hash surface.
2775                            let all_hashes: Vec<String> = match self.version {
2776                                irontide_core::TorrentVersion::V1Only
2777                                | irontide_core::TorrentVersion::Hybrid => self
2778                                    .meta
2779                                    .as_ref()
2780                                    .map(|meta| {
2781                                        meta.info
2782                                            .pieces
2783                                            .chunks_exact(20)
2784                                            .map(hex::encode)
2785                                            .collect::<Vec<String>>()
2786                                    })
2787                                    .unwrap_or_default(),
2788                                irontide_core::TorrentVersion::V2Only => self
2789                                    .meta_v2
2790                                    .as_ref()
2791                                    .map(|m| {
2792                                        m.piece_layers
2793                                            .values()
2794                                            .flat_map(|v| v.chunks_exact(32))
2795                                            .map(hex::encode)
2796                                            .collect::<Vec<String>>()
2797                                    })
2798                                    .unwrap_or_default(),
2799                            };
2800
2801                            let start = (offset as usize).min(all_hashes.len());
2802                            let end = start
2803                                .saturating_add(limit as usize)
2804                                .min(all_hashes.len());
2805                            let slice = all_hashes
2806                                .get(start..end)
2807                                .map(<[String]>::to_vec)
2808                                .unwrap_or_default();
2809                            let _ = reply.send(slice);
2810                        }
2811                        Some(TorrentCommand::SaveResumeData { reply }) => {
2812                            let result = self.build_resume_data();
2813                            let _ = reply.send(result);
2814                        }
2815                        Some(TorrentCommand::SetFilePriority { index, priority, reply }) => {
2816                            let result = self.handle_set_file_priority(index, priority);
2817                            if result.is_ok() {
2818                                self.sync_piece_states_with_wanted();
2819                                // M187: Rebuild order map and update PieceTracker wanted state.
2820                                if let Some(ref cached) = self.cached_files {
2821                                    let file_piece_ranges: Vec<(u32, u32)> = cached
2822                                        .entries
2823                                        .iter()
2824                                        .map(|e| (e.first_piece, e.last_piece))
2825                                        .collect();
2826                                    let next_gen = self.order_map_tx.borrow().generation + 1;
2827                                    let om = Arc::new(PieceOrderMap::build(
2828                                        &self.file_priorities,
2829                                        &file_piece_ranges,
2830                                        self.num_pieces,
2831                                        next_gen,
2832                                    ));
2833                                    self.order_map_tx.send_replace(om);
2834                                }
2835                                if let Some(ref mut pt) = self.piece_tracker {
2836                                    for piece in 0..self.num_pieces {
2837                                        if self.wanted_pieces.get(piece) {
2838                                            pt.mark_wanted(piece);
2839                                        } else {
2840                                            pt.mark_unwanted(piece);
2841                                        }
2842                                    }
2843                                }
2844                            }
2845                            let _ = reply.send(result);
2846                        }
2847                        Some(TorrentCommand::FilePriorities { reply }) => {
2848                            let _ = reply.send(self.file_priorities.clone());
2849                        }
2850                        Some(TorrentCommand::ForceReannounce) => {
2851                            self.tracker_manager.force_reannounce();
2852                        }
2853                        Some(TorrentCommand::TrackerList { reply }) => {
2854                            let _ = reply.send(self.tracker_manager.tracker_list());
2855                        }
2856                        Some(TorrentCommand::Scrape { reply }) => {
2857                            let result = self.tracker_manager.scrape().await;
2858                            if let Some((ref url, ref info)) = result {
2859                                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::ScrapeReply {
2860                                    info_hash: self.info_hash,
2861                                    url: url.clone(),
2862                                    complete: info.complete,
2863                                    incomplete: info.incomplete,
2864                                    downloaded: info.downloaded,
2865                                });
2866                            }
2867                            let _ = reply.send(result);
2868                        }
2869                        Some(TorrentCommand::OpenFile { file_index, reply }) => {
2870                            let result = self.handle_open_file(file_index);
2871                            let _ = reply.send(result);
2872                        }
2873                        Some(TorrentCommand::IncomingPeer { stream, addr }) => {
2874                            self.spawn_peer_from_stream_with_mode(
2875                                addr,
2876                                stream,
2877                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2878                            );
2879                        }
2880                        Some(TorrentCommand::UpdateExternalIp { ip }) => {
2881                            self.external_ip = Some(ip);
2882                            post_alert(
2883                                &self.alert_tx,
2884                                &self.alert_mask,
2885                                AlertKind::ExternalIpDetected { ip },
2886                            );
2887                        }
2888                        Some(TorrentCommand::MoveStorage { new_path, reply }) => {
2889                            let result = self.handle_move_storage(new_path).await;
2890                            let _ = reply.send(result);
2891                        }
2892                        Some(TorrentCommand::SpawnSslPeer { addr, stream }) => {
2893                            // TLS is already completed; encryption is handled by TLS layer
2894                            self.spawn_peer_from_stream_with_mode(
2895                                addr,
2896                                stream.0,
2897                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2898                            );
2899                        }
2900                        Some(TorrentCommand::SetDownloadLimit { bytes_per_sec, reply }) => {
2901                            self.download_bucket.lock().set_rate(bytes_per_sec);
2902                            let _ = reply.send(());
2903                        }
2904                        Some(TorrentCommand::SetUploadLimit { bytes_per_sec, reply }) => {
2905                            self.upload_bucket.set_rate(bytes_per_sec);
2906                            let _ = reply.send(());
2907                        }
2908                        Some(TorrentCommand::DownloadLimit { reply }) => {
2909                            let _ = reply.send(self.download_bucket.lock().rate());
2910                        }
2911                        Some(TorrentCommand::UploadLimit { reply }) => {
2912                            let _ = reply.send(self.upload_bucket.rate());
2913                        }
2914                        Some(TorrentCommand::SetSequentialDownload { enabled, reply }) => {
2915                            self.config.sequential_download = enabled;
2916                            let _ = reply.send(());
2917                        }
2918                        Some(TorrentCommand::IsSequentialDownload { reply }) => {
2919                            let _ = reply.send(self.config.sequential_download);
2920                        }
2921                        Some(TorrentCommand::SetSuperSeeding { enabled, reply }) => {
2922                            self.config.super_seeding = enabled;
2923                            self.super_seed = if enabled {
2924                                Some(crate::super_seed::SuperSeedState::new())
2925                            } else {
2926                                None
2927                            };
2928                            let _ = reply.send(());
2929                        }
2930                        Some(TorrentCommand::IsSuperSeeding { reply }) => {
2931                            let _ = reply.send(self.config.super_seeding);
2932                        }
2933                        Some(TorrentCommand::SetSeedMode { enabled, reply }) => {
2934                            self.handle_set_seed_mode(enabled);
2935                            let _ = reply.send(());
2936                        }
2937                        Some(TorrentCommand::SetSeedRatioLimit { limit, reply }) => {
2938                            self.config.seed_ratio_limit = limit;
2939                            self.need_save_resume = true;
2940                            let _ = reply.send(());
2941                        }
2942                        Some(TorrentCommand::AddTracker { url }) => {
2943                            self.tracker_manager.add_tracker_url(&url);
2944                        }
2945                        Some(TorrentCommand::ReplaceTrackers { urls, reply }) => {
2946                            self.tracker_manager.replace_all(&urls);
2947                            let _ = reply.send(());
2948                        }
2949                        Some(TorrentCommand::ForceRecheck { reply }) => {
2950                            self.handle_force_recheck(reply).await;
2951                        }
2952                        Some(TorrentCommand::RenameFile { file_index, new_name, reply }) => {
2953                            let result = self.handle_rename_file(file_index, new_name).await;
2954                            let _ = reply.send(result);
2955                        }
2956                        Some(TorrentCommand::SetMaxConnections { limit, reply }) => {
2957                            self.max_connections = limit;
2958                            let _ = reply.send(());
2959                        }
2960                        Some(TorrentCommand::MaxConnections { reply }) => {
2961                            let _ = reply.send(self.max_connections);
2962                        }
2963                        Some(TorrentCommand::SetMaxUploads { limit, reply }) => {
2964                            self.choker.set_unchoke_slots(limit);
2965                            let _ = reply.send(());
2966                        }
2967                        Some(TorrentCommand::MaxUploads { reply }) => {
2968                            let _ = reply.send(self.choker.unchoke_slots());
2969                        }
2970                        Some(TorrentCommand::GetPeerInfo { reply }) => {
2971                            let _ = reply.send(self.build_peer_info());
2972                        }
2973                        Some(TorrentCommand::GetDownloadQueue { reply }) => {
2974                            let _ = reply.send(self.build_download_queue());
2975                        }
2976                        Some(TorrentCommand::HavePiece { index, reply }) => {
2977                            let has = self.chunk_tracker.as_ref()
2978                                .is_some_and(|ct| ct.has_piece(index));
2979                            let _ = reply.send(has);
2980                        }
2981                        Some(TorrentCommand::PieceAvailability { reply }) => {
2982                            let mut avail = vec![0u32; self.num_pieces as usize];
2983                            for peer in self.peers.values() {
2984                                for i in 0..self.num_pieces {
2985                                    if peer.bitfield.get(i) {
2986                                        avail[i as usize] += 1;
2987                                    }
2988                                }
2989                            }
2990                            let _ = reply.send(avail);
2991                        }
2992                        Some(TorrentCommand::FileProgress { reply }) => {
2993                            let _ = reply.send(self.compute_file_progress());
2994                        }
2995                        Some(TorrentCommand::InfoHashes { reply }) => {
2996                            let _ = reply.send(self.info_hashes.clone());
2997                        }
2998                        Some(TorrentCommand::TorrentFile { reply }) => {
2999                            let _ = reply.send(self.meta.clone());
3000                        }
3001                        Some(TorrentCommand::TorrentFileV2 { reply }) => {
3002                            let _ = reply.send(self.meta_v2.clone());
3003                        }
3004                        Some(TorrentCommand::ForceDhtAnnounce) => {
3005                            self.handle_force_dht_announce().await;
3006                        }
3007                        Some(TorrentCommand::ReadPiece { index, reply }) => {
3008                            let result = self.handle_read_piece(index).await;
3009                            let _ = reply.send(result);
3010                        }
3011                        Some(TorrentCommand::FlushCache { reply }) => {
3012                            let result = self.handle_flush_cache().await;
3013                            let _ = reply.send(result);
3014                        }
3015                        Some(TorrentCommand::ClearError) => {
3016                            self.handle_clear_error().await;
3017                        }
3018                        Some(TorrentCommand::ClearSaveResumeFlag) => {
3019                            self.need_save_resume = false;
3020                        }
3021                        Some(TorrentCommand::RestoreResumeBitmap { pieces, reply }) => {
3022                            let result = self.handle_restore_resume_bitmap(pieces);
3023                            let _ = reply.send(result);
3024                        }
3025                        Some(TorrentCommand::RestoreWebSeedStats { stats, reply }) => {
3026                            self.web_seed_stats = stats;
3027                            let _ = reply.send(Ok(()));
3028                        }
3029                        Some(TorrentCommand::GetPeerSourceCounts { reply }) => {
3030                            let _ = reply.send((self.pex_peer_count, self.lsd_peer_count));
3031                        }
3032                        Some(TorrentCommand::QueryUnchokeDurations { reply }) => {
3033                            let mut out = self.unchoke_durations.clone();
3034                            // Merge in each currently-live peer's transient
3035                            // accumulator + any in-flight unchoke window.
3036                            let now = Instant::now();
3037                            for peer in self.peers.values() {
3038                                let mut delta = peer.unchoke_duration_total;
3039                                if let Some(start) = peer.am_unchoke_started_at {
3040                                    delta += now.duration_since(start);
3041                                }
3042                                if !delta.is_zero() {
3043                                    *out.entry(peer.addr).or_default() += delta;
3044                                }
3045                            }
3046                            let _ = reply.send(out);
3047                        }
3048                        Some(TorrentCommand::GetWebSeedStats { reply }) => {
3049                            let snapshot: Vec<_> = self.web_seed_stats.values().cloned().collect();
3050                            let _ = reply.send(snapshot);
3051                        }
3052                        Some(TorrentCommand::FileStatus { reply }) => {
3053                            let _ = reply.send(self.build_file_status());
3054                        }
3055                        Some(TorrentCommand::Flags { reply }) => {
3056                            let _ = reply.send(self.build_flags());
3057                        }
3058                        Some(TorrentCommand::SetFlags { flags, reply }) => {
3059                            self.apply_set_flags(flags).await;
3060                            let _ = reply.send(());
3061                        }
3062                        Some(TorrentCommand::UnsetFlags { flags, reply }) => {
3063                            self.apply_unset_flags(flags).await;
3064                            let _ = reply.send(());
3065                        }
3066                        Some(TorrentCommand::ConnectPeer { addr }) => {
3067                            self.handle_connect_peer(addr);
3068                        }
3069                        Some(TorrentCommand::PreResolvedMetadata { info_bytes, peers }) => {
3070                            self.handle_pre_resolved_metadata(info_bytes, peers).await;
3071                        }
3072                        #[cfg(feature = "test-util")]
3073                        Some(TorrentCommand::TestInjectMetadata { info_bytes, reply }) => {
3074                            // Reuses the existing handle_pre_resolved_metadata at torrent.rs:3665.
3075                            // Synchronous because the test caller depends on completion before
3076                            // proceeding (unlike the production resolver which is fire-and-forget).
3077                            self.handle_pre_resolved_metadata(info_bytes, vec![]).await;
3078                            let _ = reply.send(());
3079                        }
3080                        Some(TorrentCommand::GetMeta { reply }) => {
3081                            // v0.173.1: single source of truth for torrent
3082                            // metadata — replaces `SessionActor.TorrentEntry.meta`
3083                            // so magnet-added torrents no longer lose the info
3084                            // dict between the TorrentActor and the session.
3085                            let _ = reply.send(self.meta.clone());
3086                        }
3087                        Some(TorrentCommand::UpdateSettings(delta)) => {
3088                            self.handle_update_settings(&delta);
3089                        }
3090                        Some(TorrentCommand::Shutdown) => {
3091                            info!("torrent actor: received Shutdown command, exiting");
3092                            self.shutdown_web_seeds().await;
3093                            self.shutdown_peers().await;
3094                            return;
3095                        }
3096                        None => {
3097                            warn!("torrent actor: cmd_rx channel closed (all senders dropped), exiting");
3098                            self.shutdown_web_seeds().await;
3099                            self.shutdown_peers().await;
3100                            return;
3101                        }
3102                    }
3103                }
3104                // Async disk write errors
3105                Some(err) = self.write_error_rx.recv() => {
3106                    warn!(piece = err.piece, begin = err.begin, "async disk write failed: {}", err.error);
3107                }
3108                // Accept incoming peers
3109                result = accept_incoming(&mut self.listener) => {
3110                    if let Ok((stream, addr)) = result {
3111                        self.spawn_peer_from_stream(addr, stream);
3112                    }
3113                }
3114                // Accept incoming I2P peers (M41)
3115                stream = accept_i2p(&mut self.i2p_accept_rx) => {
3116                    if let Some(stream) = stream {
3117                        self.handle_i2p_incoming(stream);
3118                    }
3119                }
3120                // Rate update timer (2s) — decoupled from the 10s unchoke
3121                // interval so the GUI sees responsive DL/UL numbers.
3122                _ = rate_interval.tick() => {
3123                    self.update_peer_rates();
3124                }
3125                // Unchoke timer
3126                _ = unchoke_interval.tick() => {
3127                    // M144 deviation from BEP 3 §3 (10s choking algorithm):
3128                    // We skip the choker during download — intentional rqbit-parity
3129                    // decision. BEP 3 specifies a 10s choking interval to manage
3130                    // upload slots via tit-for-tat. But IronTide unchokes all peers
3131                    // unconditionally on connect (M107); running the choker every
3132                    // 10s re-chokes most peers, breaking the reciprocity that drove
3133                    // the original unchoke. Only run during seeding/sharing where
3134                    // upload-slot management matters. Reviewed M174.
3135                    if self.state == TorrentState::Seeding
3136                        || self.state == TorrentState::Sharing
3137                    {
3138                        self.slot_tuner.observe(self.upload_bytes_interval);
3139                        self.choker.observe_throughput(self.upload_bytes_interval);
3140                        self.upload_bytes_interval = 0;
3141                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3142                        self.run_choker().await;
3143                        // v0.187.3: a normal tick satisfies the immediate-tick
3144                        // request, so clear the flag.
3145                        self.force_immediate_choker_tick = false;
3146                    } else {
3147                        self.upload_bytes_interval = 0;
3148                    }
3149                    // Update streaming cursors and piece priorities
3150                    self.update_streaming_cursors();
3151                    // Update auto-sequential hysteresis (M45)
3152                    if self.config.auto_sequential {
3153                        self.auto_sequential_active = crate::piece_selector::evaluate_auto_sequential(
3154                            self.piece_owner.iter().filter(|o| o.is_some()).count(),
3155                            self.peers.len(),
3156                            self.auto_sequential_active,
3157                        );
3158                    }
3159                    // Periodic web seed piece reassignment (moved from dht_recheck timer)
3160                    self.assign_pieces_to_web_seeds();
3161                }
3162                // Optimistic unchoke timer
3163                _ = optimistic_interval.tick() => {
3164                    self.rotate_optimistic();
3165                }
3166                // M107: Receive connect requests from the peer adder task
3167                Some(connect_peer) = async {
3168                    match self.connect_rx.as_mut() {
3169                        Some(rx) => rx.recv().await,
3170                        None => std::future::pending().await,
3171                    }
3172                } => {
3173                    self.handle_adder_connect(connect_peer);
3174                }
3175                () = &mut dht_requery_sleep, if self.state != TorrentState::Complete
3176                    && self.state != TorrentState::Paused
3177                    && self.state != TorrentState::Queued
3178                    && self.state != TorrentState::Seeding
3179                    && self.state != TorrentState::Stopped => {
3180                    self.run_dht_requery().await;
3181                    dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::from_mins(1)));
3182                }
3183                // M143: Tracker re-announce timer — starts a background announce,
3184                // never blocks. Only fires when no announce is in-flight.
3185                // Also fires during FetchingMetadata so magnets with &tr= URLs
3186                // can discover peers before metadata arrives.
3187                () = async {
3188                    match self.tracker_manager.next_announce_in() {
3189                        Some(dur) => tokio::time::sleep(dur).await,
3190                        None => std::future::pending().await,
3191                    }
3192                }, if self.tracker_result_rx.is_none() => {
3193                    let left = self.calculate_left();
3194                    self.tracker_result_rx = Some(self.tracker_manager.start_announce(
3195                        irontide_tracker::AnnounceEvent::None,
3196                        self.uploaded,
3197                        self.downloaded,
3198                        left,
3199                    ));
3200                }
3201                // M143: Streaming tracker results — process each tracker
3202                // response as it arrives, without blocking the actor loop.
3203                result = async {
3204                    match self.tracker_result_rx.as_mut() {
3205                        Some(rx) => rx.recv().await,
3206                        None => std::future::pending().await,
3207                    }
3208                } => {
3209                    match result {
3210                        Some(batch) => {
3211                            let (peers, outcome) = self.tracker_manager.process_tracker_result(batch);
3212                            self.fire_tracker_alerts(&[outcome]);
3213                            if !peers.is_empty() {
3214                                debug!(count = peers.len(), "tracker returned peers (streaming)");
3215                                self.handle_add_peers(peers, PeerSource::Tracker);
3216                            }
3217                        }
3218                        None => {
3219                            // All trackers responded — clear in-flight state so
3220                            // the timer arm can re-fire for the next announce cycle.
3221                            self.tracker_result_rx = None;
3222                        }
3223                    }
3224                }
3225                // DHT v4 peer discovery
3226                result = async {
3227                    match &mut self.dht_peers_rx {
3228                        Some(rx) => rx.recv().await,
3229                        None => std::future::pending().await,
3230                    }
3231                } => {
3232                    if let Some(peers) = result {
3233                        debug!(count = peers.len(), "DHT v4 returned peers");
3234                        self.handle_add_peers(peers, PeerSource::Dht);
3235                    } else {
3236                        debug!("DHT v4 peer search exhausted");
3237                        self.dht_peers_rx = None;
3238                    }
3239                }
3240                // DHT v6 peer discovery
3241                result = async {
3242                    match &mut self.dht_v6_peers_rx {
3243                        Some(rx) => rx.recv().await,
3244                        None => std::future::pending().await,
3245                    }
3246                } => {
3247                    if let Some(peers) = result {
3248                        debug!(count = peers.len(), "DHT v6 returned peers");
3249                        self.dht_v6_empty_count = 0; // V6 is working, reset
3250                        self.handle_add_peers(peers, PeerSource::Dht);
3251                    } else {
3252                        self.dht_v6_peers_rx = None;
3253                        self.dht_v6_empty_count += 1;
3254                        if self.dht_v6_empty_count == 30 {
3255                            debug!("DHT v6 routing table persistently empty, giving up");
3256                        } else if self.dht_v6_empty_count < 30 {
3257                            debug!("DHT v6 peer search exhausted");
3258                        }
3259                    }
3260                }
3261                // Dual-swarm: DHT v4 v2-hash peer discovery (hybrid)
3262                result = async {
3263                    match &mut self.dht_v2_peers_rx {
3264                        Some(rx) => rx.recv().await,
3265                        None => std::future::pending().await,
3266                    }
3267                } => {
3268                    if let Some(peers) = result {
3269                        debug!(count = peers.len(), "DHT v4 v2-swarm returned peers");
3270                        self.handle_add_peers(peers, PeerSource::Dht);
3271                    } else {
3272                        debug!("DHT v4 v2-swarm peer search exhausted");
3273                        self.dht_v2_peers_rx = None;
3274                    }
3275                }
3276                // Dual-swarm: DHT v6 v2-hash peer discovery (hybrid)
3277                result = async {
3278                    match &mut self.dht_v6_v2_peers_rx {
3279                        Some(rx) => rx.recv().await,
3280                        None => std::future::pending().await,
3281                    }
3282                } => {
3283                    if let Some(peers) = result {
3284                        debug!(count = peers.len(), "DHT v6 v2-swarm returned peers");
3285                        self.handle_add_peers(peers, PeerSource::Dht);
3286                    } else {
3287                        debug!("DHT v6 v2-swarm peer search exhausted");
3288                        self.dht_v6_v2_peers_rx = None;
3289                    }
3290                }
3291                // M44: Suggest cached pieces timer
3292                _ = async {
3293                    match suggest_interval {
3294                        Some(ref mut interval) => interval.tick().await,
3295                        None => std::future::pending().await,
3296                    }
3297                } => {
3298                    self.suggest_cached_pieces().await;
3299                }
3300                _ = turnover_interval.tick() => {
3301                    self.run_steal_queue_maintenance();
3302                }
3303                // Pipeline tick (1s) — update EWMA, snub detection, peer scoring
3304                _ = pipeline_tick_interval.tick() => {
3305                    let snub_timeout = Duration::from_secs(u64::from(self.config.snub_timeout_secs));
3306
3307                    for (_addr, peer) in &mut self.peers {
3308                        peer.pipeline.tick();
3309
3310                        // Snub detection: no data for snub_timeout_secs while unchoked
3311                        if !peer.peer_choking && !peer.snubbed {
3312                            let idle = peer.last_data_received
3313                                .is_some_and(|t| t.elapsed() > snub_timeout);
3314                            if idle {
3315                                peer.snubbed = true;
3316                                // M106: Count pending requests as timed-out blocks
3317                                peer.blocks_timed_out = peer.blocks_timed_out
3318                                    .saturating_add(peer.pending_requests.len() as u64);
3319                                debug!(%_addr, "peer snubbed (no data for {}s)", self.config.snub_timeout_secs);
3320                            }
3321                        }
3322                    }
3323
3324                    // Refresh cached peer rates for steal decisions (avoids
3325                    // rebuilding a FxHashMap from all peers on every block arrival).
3326                    self.refresh_peer_rates();
3327
3328                    // M73: Periodic endgame activation check (was in batch_fill_all_peers)
3329                    if !self.end_game.is_active() {
3330                        self.check_end_game_activation();
3331                    }
3332
3333                    self.tick_dispatch_safety_wake();
3334
3335                    // M138: Proactive choke rotation — every tick, evict up to N choked peers
3336                    if self.config.choke_rotation_max_evictions > 0
3337                        && self.state == TorrentState::Downloading
3338                    {
3339                        self.run_choke_rotation();
3340                    }
3341                }
3342                // (M75: peer tasks handle dispatch via integrated select! arm)
3343                // End-game refill tick (200ms) — replace reactive per-block cascade
3344                // with periodic batch refill. All peers with available pipeline slots
3345                // get new end-game blocks, preventing idle stalls between ticks.
3346                _ = end_game_tick_interval.tick(), if self.end_game.is_active() => {
3347                    let addrs: Vec<SocketAddr> = self.peers.iter()
3348                        .filter(|(_, p)| !p.peer_choking && p.pending_requests.len() < END_GAME_DEPTH)
3349                        .map(|(addr, _)| *addr)
3350                        .collect();
3351                    for addr in addrs {
3352                        self.request_end_game_block(addr).await;
3353                    }
3354                }
3355                // M107: Metadata piece timeout — re-request timed-out pieces from
3356                // all non-rejected peers that support ut_metadata.
3357                _ = metadata_timeout_interval.tick(), if self.state == TorrentState::FetchingMetadata => {
3358                    // Collect timed-out pieces (immutable borrow, then release).
3359                    let timed_out: Vec<u32> = self
3360                        .metadata_downloader
3361                        .as_ref()
3362                        .map(MetadataDownloader::timed_out_pieces)
3363                        .unwrap_or_default();
3364
3365                    if !timed_out.is_empty() {
3366                        debug!(count = timed_out.len(), "metadata pieces timed out, re-requesting");
3367
3368                        // Collect eligible peers (non-rejected, support ut_metadata).
3369                        // Clone cmd_tx to avoid holding borrows across the send loop.
3370                        let eligible_senders: Vec<mpsc::Sender<PeerCommand>> = self
3371                            .peers
3372                            .iter()
3373                            .filter(|(addr, peer)| {
3374                                self.metadata_downloader
3375                                    .as_ref()
3376                                    .is_some_and(|dl| !dl.is_rejected(addr))
3377                                    && peer
3378                                        .ext_handshake
3379                                        .as_ref()
3380                                        .is_some_and(|h| h.metadata_size.is_some())
3381                            })
3382                            .map(|(_, peer)| peer.cmd_tx.clone())
3383                            .collect();
3384
3385                        // Send requests (uses cloned senders, no borrow conflict).
3386                        for cmd_tx in &eligible_senders {
3387                            for &piece in &timed_out {
3388                                let _ = cmd_tx.try_send(PeerCommand::RequestMetadata { piece });
3389                            }
3390                        }
3391
3392                        // Update request times in the downloader.
3393                        if let Some(ref mut dl) = self.metadata_downloader {
3394                            for piece in timed_out {
3395                                dl.reset_request_time(piece);
3396                            }
3397                        }
3398                    }
3399                }
3400                // Periodic download status report (5s)
3401                _ = diag_interval.tick() => {
3402                    // Heartbeat: log state regardless of download state
3403                    {
3404                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3405                        let eg = self.end_game.is_active();
3406                        let eg_blocks = self.end_game.block_count();
3407                        info!(state = ?self.state, have, total = self.num_pieces, end_game = eg, eg_blocks, "heartbeat");
3408                    }
3409                    if self.state == TorrentState::Downloading {
3410                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3411                        let in_flight = self.atomic_states.as_ref().map_or(0, |s| s.in_flight_count() as usize);
3412                        let unchoked = self.peers.values().filter(|p| !p.peer_choking).count();
3413                        info!(have, in_flight, total = self.num_pieces,
3414                              downloaded_mb = self.downloaded / (1024 * 1024),
3415                              peers = self.peers.len(), unchoked,
3416                              "download progress");
3417                        for (addr, p) in &self.peers {
3418                            let last_data = p.last_data_received.map_or(9999, |t| t.elapsed().as_secs());
3419                            trace!(%addr,
3420                                   choking = p.peer_choking,
3421                                   pending = p.pending_requests.len(),
3422                                   ewma_rate = p.pipeline.ewma_rate() as u64,
3423                                   last_data_secs = last_data,
3424                                   bf_ones = p.bitfield.count_ones(),
3425                                   "peer state");
3426                        }
3427                    }
3428                }
3429                // M108: 30s connection success rate summary for variance diagnosis
3430                _ = conn_stats_interval.tick() => {
3431                    if self.connect_attempts > 0 {
3432                        let succeeded = self.connect_attempts.saturating_sub(self.connect_failures);
3433                        let success_pct = (succeeded as f64 / self.connect_attempts as f64 * 100.0) as u32;
3434                        info!(
3435                            connected = self.peers.len(),
3436                            attempted = self.connect_attempts,
3437                            failed = self.connect_failures,
3438                            success_rate = %format!("{success_pct}%"),
3439                            "connection stats"
3440                        );
3441                    }
3442                }
3443                // M147: Soft reap — disconnect connecting peers without TCP SYN-ACK.
3444                // v0.173.3: uses the buffer-fill variant + index iteration to reuse
3445                // soft_reap_buf across ticks without ever moving its heap allocation.
3446                // SocketAddr is Copy, so indexing into self.soft_reap_buf yields a
3447                // value copy and does not borrow self.soft_reap_buf for the loop body.
3448                _ = soft_reap_interval.tick() => {
3449                    let soft_timeout = self.config.connect_soft_timeout;
3450                    if soft_timeout > 0 {
3451                        if let Some(ref ps) = self.peer_states {
3452                            ps.soft_reap_candidates_into(
3453                                Duration::from_secs(soft_timeout),
3454                                &mut self.soft_reap_buf,
3455                            );
3456                        } else {
3457                            self.soft_reap_buf.clear();
3458                        }
3459                        for i in 0..self.soft_reap_buf.len() {
3460                            let peer_addr = self.soft_reap_buf[i];
3461                            debug!(%peer_addr, soft_timeout, "soft reap: no TCP SYN-ACK");
3462                            // Remove from connect_permits so RAII drops the permit
3463                            self.connect_permits.remove(&peer_addr);
3464                            self.disconnect_peer(peer_addr, "soft reap: no TCP SYN-ACK");
3465                            if let Some(ref ps) = self.peer_states
3466                                && let Some(backoff) = ps.mark_dead(peer_addr)
3467                            {
3468                                let ps_clone = Arc::clone(ps);
3469                                tokio::spawn(async move {
3470                                    tokio::time::sleep(backoff).await;
3471                                    ps_clone.mark_queued_for_retry(peer_addr);
3472                                });
3473                            }
3474                        }
3475                        self.soft_reap_buf.clear();
3476                    }
3477                }
3478                // M148 + v0.187.3: Proactive eviction with churn guard.
3479                //
3480                //                  PROACTIVE EVICTION POLICY (M148 → v0.187.3)
3481                //                  ============================================
3482                //
3483                //   Tick every 2s
3484                //   ├─ state == Seeding?                          ──── no-op
3485                //   ├─ live < (effective_max * 0.95)?             ──── no-op
3486                //   └─ eviction_history (in last 60s) < limit?    ──── no-op
3487                //       │
3488                //       ▼
3489                //   for up to 5 candidates:
3490                //     find_eviction_candidate() →
3491                //       Pass 0  ZeroThroughput     [skipped if state==Seeding]
3492                //               [skipped if peer.live_since < pass0_grace_secs]
3493                //               ban 10min, push to banned_set (FIFO cap 1024)
3494                //       Pass 1  Choked > 10s       no ban
3495                //       Pass 2  LowThroughput      no ban
3496                //       Pass 3  Bep40 priority     [only on HandshakeComplete]
3497                //               no ban
3498                //
3499                //     on evict: eviction_history.push_back(now)
3500                _ = eviction_interval.tick() => {
3501                    // v0.187.3 / Bug 8a: opportunistically service a pending
3502                    // immediate-tick request from a recent state transition
3503                    // (typically Downloading → Seeding). Caps the worst-case
3504                    // first-unchoke latency at the 2s eviction interval
3505                    // instead of the 10s unchoke interval.
3506                    if self.force_immediate_choker_tick
3507                        && (self.state == TorrentState::Seeding
3508                            || self.state == TorrentState::Sharing)
3509                    {
3510                        self.slot_tuner.observe(self.upload_bytes_interval);
3511                        self.choker.observe_throughput(self.upload_bytes_interval);
3512                        self.upload_bytes_interval = 0;
3513                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3514                        self.run_choker().await;
3515                        self.force_immediate_choker_tick = false;
3516                    }
3517                    if self.state != TorrentState::Seeding {
3518                        // v0.187.3 / 3A: prune eviction_history of entries older
3519                        // than 60s, then gate on the configured limit.
3520                        let prune_cutoff = std::time::Duration::from_mins(1);
3521                        while self
3522                            .eviction_history
3523                            .front()
3524                            .copied()
3525                            .is_some_and(|t| t.elapsed() > prune_cutoff)
3526                        {
3527                            self.eviction_history.pop_front();
3528                        }
3529                        let limit = self.config.proactive_evictions_per_minute_limit as usize;
3530                        let window_ok = self.eviction_history.len() < limit;
3531
3532                        // v0.187.3 / Pressure gate: 0.95 (was 0.75 in v0.187.2).
3533                        // Higher threshold gives slow-start peers room to ramp
3534                        // before the eviction loop fires.
3535                        let should_evict = window_ok
3536                            && self.peer_states.as_ref().is_some_and(|ps| {
3537                                let live = ps
3538                                    .stats
3539                                    .live
3540                                    .load(std::sync::atomic::Ordering::Relaxed);
3541                                #[allow(
3542                                    clippy::cast_possible_truncation,
3543                                    clippy::cast_sign_loss
3544                                )]
3545                                let threshold =
3546                                    (self.effective_max_connections() as f32 * 0.95) as u32;
3547                                debug_assert!(
3548                                    self.effective_max_connections()
3549                                        <= crate::torrent_peers::HARD_PEER_CEILING,
3550                                    "effective_max must be clamped to HARD_PEER_CEILING"
3551                                );
3552                                live >= threshold
3553                            });
3554                        if should_evict {
3555                            // Evict up to 5 deadweight per tick, but no more than
3556                            // (limit - history.len()) total per the sliding window.
3557                            let max_this_tick = 5.min(limit.saturating_sub(self.eviction_history.len()));
3558                            for _ in 0..max_this_tick {
3559                                match self.find_eviction_candidate() {
3560                                    Some((victim, pass)) => {
3561                                        debug!(%victim, ?pass, "v0.187.3 proactive eviction");
3562                                        self.disconnect_peer(victim, "proactive eviction");
3563                                        if matches!(pass, crate::torrent_peers::EvictionPass::ZeroThroughput)
3564                                            && let Some(ref ps) = self.peer_states
3565                                        {
3566                                            ps.add_eviction_ban(victim);
3567                                        }
3568                                        self.eviction_history.push_back(std::time::Instant::now());
3569                                    }
3570                                    None => break,
3571                                }
3572                            }
3573                        }
3574
3575                        // M149: Piece stealing scan (piggybacks on same 2s interval)
3576                        self.run_piece_steal_scan();
3577                    }
3578                }
3579                // Rate limiter refill (100ms)
3580                _ = refill_interval.tick() => {
3581                    let elapsed = Duration::from_millis(100);
3582                    self.upload_bucket.refill(elapsed);
3583                    self.download_bucket.lock().refill(elapsed);
3584                    // Refill per-class buckets and apply mixed-mode (M45)
3585                    self.rate_limiter_set.refill(elapsed);
3586                    let (tcp_peers, utp_peers) = self.transport_peer_counts();
3587                    self.rate_limiter_set.apply_mixed_mode(
3588                        self.config.mixed_mode_algorithm,
3589                        tcp_peers,
3590                        utp_peers,
3591                        self.config.upload_rate_limit,
3592                    );
3593                }
3594            }
3595
3596            // M112: drain holepunch attempts (bridging sync disconnect_peer → async try_holepunch)
3597            for target in std::mem::take(&mut self.holepunch_pending) {
3598                self.try_holepunch(target).await;
3599            }
3600        }
3601    }
3602
3603    // ----- Command handlers -----
3604
3605    /// Compute distributed copy availability across the swarm.
3606    ///
3607    /// Returns `(full_copies, fraction, copies_float)` where `fraction` is in thousandths.
3608    pub(crate) fn distributed_copies(&self) -> (u32, u32, f32) {
3609        if self.num_pieces == 0 || self.peers.is_empty() {
3610            return (0, 0, 0.0);
3611        }
3612
3613        let num = self.num_pieces as usize;
3614        let mut availability = vec![0u32; num];
3615
3616        for peer in self.peers.values() {
3617            for idx in 0..self.num_pieces {
3618                if peer.bitfield.get(idx) {
3619                    availability[idx as usize] += 1;
3620                }
3621            }
3622        }
3623
3624        let min_avail = availability.iter().copied().min().unwrap_or(0);
3625        let rarest_count = availability.iter().filter(|&&c| c == min_avail).count() as u32;
3626        let fraction = ((self.num_pieces - rarest_count) * 1000) / self.num_pieces;
3627        let copies_float = min_avail as f32 + fraction as f32 / 1000.0;
3628
3629        (min_avail, fraction, copies_float)
3630    }
3631
3632    fn build_download_queue(&self) -> Vec<PartialPieceInfo> {
3633        self.piece_owner
3634            .iter()
3635            .enumerate()
3636            .filter_map(|(piece_index, owner)| {
3637                owner.map(|_| {
3638                    let piece_index = piece_index as u32;
3639                    let blocks_in_piece = self
3640                        .lengths
3641                        .as_ref()
3642                        .map_or(0, |l| l.piece_size(piece_index).div_ceil(l.chunk_size()));
3643                    PartialPieceInfo {
3644                        piece_index,
3645                        blocks_in_piece,
3646                        blocks_assigned: 0,
3647                    }
3648                })
3649            })
3650            .collect()
3651    }
3652
3653    /// Compute per-file downloaded bytes.
3654    fn compute_file_progress(&self) -> Vec<u64> {
3655        let Some(meta) = self.meta.as_ref() else {
3656            return Vec::new();
3657        };
3658        let Some(lengths) = self.lengths.as_ref() else {
3659            return Vec::new();
3660        };
3661        let Some(chunk_tracker) = self.chunk_tracker.as_ref() else {
3662            return Vec::new();
3663        };
3664
3665        let files = meta.info.files();
3666        if files.is_empty() {
3667            return Vec::new();
3668        }
3669
3670        let piece_length = lengths.piece_length();
3671        let mut result = Vec::with_capacity(files.len());
3672        let mut file_offset = 0u64;
3673
3674        for file_entry in &files {
3675            let file_len = file_entry.length;
3676            if file_len == 0 {
3677                result.push(0);
3678                file_offset += file_len;
3679                continue;
3680            }
3681
3682            let file_end = file_offset + file_len;
3683            let first_piece = (file_offset / piece_length) as u32;
3684            let last_piece = ((file_end - 1) / piece_length) as u32;
3685
3686            let mut downloaded = 0u64;
3687
3688            for p in first_piece..=last_piece {
3689                if !chunk_tracker.has_piece(p) {
3690                    continue;
3691                }
3692
3693                let piece_start = lengths.piece_offset(p);
3694                let piece_end = piece_start + u64::from(lengths.piece_size(p));
3695
3696                // Clamp to file boundaries
3697                let overlap_start = piece_start.max(file_offset);
3698                let overlap_end = piece_end.min(file_end);
3699
3700                if overlap_start < overlap_end {
3701                    downloaded += overlap_end - overlap_start;
3702                }
3703            }
3704
3705            result.push(downloaded);
3706            file_offset = file_end;
3707        }
3708
3709        result
3710    }
3711
3712    /// Exponential backoff delay for V6 DHT retries (M97).
3713    /// 100ms, 200ms, 400ms, 800ms, 1600ms, 3200ms, 5000ms (cap).
3714    fn v6_retry_delay(&self) -> std::time::Duration {
3715        let base_ms: u64 = 100;
3716        let max_ms: u64 = 5000;
3717        let delay_ms = base_ms
3718            .saturating_mul(
3719                1u64.checked_shl(self.dht_v6_empty_count)
3720                    .unwrap_or(u64::MAX),
3721            )
3722            .min(max_ms);
3723        std::time::Duration::from_millis(delay_ms)
3724    }
3725
3726    /// Check if enough time has elapsed for the next V6 DHT retry (M97).
3727    fn should_retry_v6(&self) -> bool {
3728        let Some(last) = self.dht_v6_last_retry else {
3729            return true; // First attempt
3730        };
3731        last.elapsed() >= self.v6_retry_delay()
3732    }
3733
3734    /// Force an immediate DHT announce on all available DHT handles (v4 + v6).
3735    async fn handle_force_dht_announce(&self) {
3736        if let Some(dht) = self.current_dht()
3737            && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
3738        {
3739            warn!("Force DHT v4 announce failed: {e}");
3740        }
3741        if let Some(dht6) = self.current_dht_v6()
3742            && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
3743        {
3744            debug!("Force DHT v6 announce failed: {e}");
3745        }
3746        // Dual-swarm: also announce v2 hash for hybrid torrents
3747        if self.info_hashes.is_hybrid()
3748            && let Some(v2) = self.info_hashes.v2
3749        {
3750            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
3751            if v2_as_v1 != self.info_hash {
3752                if let Some(dht) = self.current_dht()
3753                    && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
3754                {
3755                    debug!("Force DHT v4 dual-swarm announce failed: {e}");
3756                }
3757                if let Some(dht6) = self.current_dht_v6()
3758                    && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
3759                {
3760                    debug!("Force DHT v6 dual-swarm announce failed: {e}");
3761                }
3762            }
3763        }
3764    }
3765
3766    /// M107: Periodic DHT re-query — discovers new peers during download.
3767    ///
3768    /// Replaces the old fixed 30s `dht_recheck_interval`. Clears the adder's
3769    /// seen set so previously-known peers can be re-evaluated, then issues
3770    /// fresh `get_peers` on all active DHT handles (v4, v6, v2-swarm).
3771    async fn run_dht_requery(&mut self) {
3772        if !self.config.enable_dht {
3773            return;
3774        }
3775
3776        // Guard: don't re-query if we already have plenty of known peers.
3777        // M133: Scale with config instead of hardcoded 500 — with max_peers=128
3778        // this becomes 512, close to the old value but adapts to custom limits.
3779        if self.peers.len() > self.config.max_peers.saturating_mul(4) {
3780            return;
3781        }
3782
3783        // M134: DhtLookup is now persistent — it re-injects routing table roots
3784        // every 15s internally. Only issue a fresh get_peers if the previous
3785        // lookup's channel has closed (lookup exhausted or aborted). Issuing a
3786        // new get_peers while one is active would abort the existing DhtLookup,
3787        // destroying its accumulated 256-node state.
3788
3789        // v4 DHT — only start if no active lookup
3790        if self.dht_peers_rx.is_none()
3791            && let Some(dht) = self.current_dht()
3792        {
3793            match dht.get_peers(self.info_hash).await {
3794                Ok(rx) => self.dht_peers_rx = Some(rx),
3795                Err(e) => warn!("DHT v4 re-query failed: {e}"),
3796            }
3797        }
3798
3799        // v6 DHT — only start if no active lookup
3800        if self.dht_v6_peers_rx.is_none()
3801            && self.dht_v6_empty_count < 30
3802            && self.should_retry_v6()
3803            && let Some(dht6) = self.current_dht_v6()
3804        {
3805            self.dht_v6_last_retry = Some(std::time::Instant::now());
3806            match dht6.get_peers(self.info_hash).await {
3807                Ok(rx) => self.dht_v6_peers_rx = Some(rx),
3808                Err(e) => debug!("DHT v6 re-query failed: {e}"),
3809            }
3810        }
3811
3812        // v2 swarm re-query for hybrid torrents — only start if no active lookup
3813        if self.info_hashes.is_hybrid()
3814            && let Some(v2) = self.info_hashes.v2
3815        {
3816            let v2_bytes: [u8; 20] = v2.0[..20]
3817                .try_into()
3818                .expect("Id32 is 32 bytes; first 20 always fit");
3819            let v2_as_v1 = Id20(v2_bytes);
3820
3821            if self.dht_v2_peers_rx.is_none()
3822                && let Some(dht) = self.current_dht()
3823            {
3824                match dht.get_peers(v2_as_v1).await {
3825                    Ok(rx) => self.dht_v2_peers_rx = Some(rx),
3826                    Err(e) => debug!("DHT v4 v2-swarm re-query failed: {e}"),
3827                }
3828            }
3829            if self.dht_v6_v2_peers_rx.is_none()
3830                && self.dht_v6_empty_count < 30
3831                && self.should_retry_v6()
3832                && let Some(dht6) = self.current_dht_v6()
3833            {
3834                self.dht_v6_last_retry = Some(std::time::Instant::now());
3835                match dht6.get_peers(v2_as_v1).await {
3836                    Ok(rx) => self.dht_v6_v2_peers_rx = Some(rx),
3837                    Err(e) => debug!("DHT v6 v2-swarm re-query failed: {e}"),
3838                }
3839            }
3840        }
3841
3842        debug!(peers = self.peers.len(), "DHT re-query triggered");
3843    }
3844
3845    /// Read a complete piece from disk by reading all chunks and concatenating.
3846    async fn handle_read_piece(&self, index: u32) -> crate::Result<Bytes> {
3847        let disk = self
3848            .disk
3849            .as_ref()
3850            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3851        let lengths = self
3852            .lengths
3853            .as_ref()
3854            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3855
3856        let piece_size = lengths.piece_size(index);
3857        if piece_size == 0 {
3858            return Err(crate::Error::InvalidPieceIndex {
3859                index,
3860                num_pieces: lengths.num_pieces(),
3861            });
3862        }
3863
3864        let chunk_size = lengths.chunk_size();
3865        let num_chunks = lengths.chunks_in_piece(index);
3866        let mut buf = bytes::BytesMut::with_capacity(piece_size as usize);
3867
3868        for chunk_idx in 0..num_chunks {
3869            let begin = chunk_idx * chunk_size;
3870            let len = if chunk_idx == num_chunks - 1 {
3871                piece_size - begin
3872            } else {
3873                chunk_size
3874            };
3875            let data = disk
3876                .read_chunk(index, begin, len, DiskJobFlags::empty())
3877                .await
3878                .map_err(crate::Error::Storage)?;
3879            buf.extend_from_slice(&data);
3880        }
3881
3882        Ok(buf.freeze())
3883    }
3884
3885    /// Flush the disk write cache.
3886    async fn handle_flush_cache(&self) -> crate::Result<()> {
3887        let disk = self
3888            .disk
3889            .as_ref()
3890            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3891        disk.flush_cache().await.map_err(crate::Error::Storage)
3892    }
3893
3894    /// Immediately initiate a connection to the given peer address.
3895    fn handle_connect_peer(&mut self, addr: SocketAddr) {
3896        // Skip if already connected
3897        if self.peers.contains_key(&addr) {
3898            return;
3899        }
3900        // M137: Track via unified PeerStates lifecycle
3901        if let Some(ref ps) = self.peer_states {
3902            ps.add_if_not_seen(addr, PeerSource::Incoming);
3903        }
3904    }
3905
3906    /// Fire `TrackerReply` / `TrackerError` alerts from announce outcomes.
3907    pub(crate) fn fire_tracker_alerts(&self, outcomes: &[crate::tracker_manager::TrackerOutcome]) {
3908        for outcome in outcomes {
3909            match &outcome.result {
3910                Ok(num_peers) => {
3911                    post_alert(
3912                        &self.alert_tx,
3913                        &self.alert_mask,
3914                        AlertKind::TrackerReply {
3915                            info_hash: self.info_hash,
3916                            url: outcome.url.clone(),
3917                            num_peers: *num_peers,
3918                        },
3919                    );
3920                }
3921                Err(msg) => {
3922                    post_alert(
3923                        &self.alert_tx,
3924                        &self.alert_mask,
3925                        AlertKind::TrackerError {
3926                            info_hash: self.info_hash,
3927                            url: outcome.url.clone(),
3928                            message: msg.clone(),
3929                        },
3930                    );
3931                }
3932            }
3933        }
3934    }
3935
3936    /// Calculate bytes remaining for tracker announce.
3937    pub(crate) fn calculate_left(&self) -> u64 {
3938        match (&self.meta, &self.chunk_tracker) {
3939            (Some(meta), Some(ct)) => {
3940                let total = meta.info.total_length();
3941                let have = u64::from(ct.bitfield().count_ones());
3942                let pieces_total = u64::from(self.num_pieces);
3943                let per_piece = total.checked_div(pieces_total).unwrap_or(0);
3944                total.saturating_sub(have * per_piece)
3945            }
3946            _ => 0,
3947        }
3948    }
3949
3950    pub(crate) async fn shutdown_peers(&mut self) {
3951        // Best-effort announce Stopped to trackers (with timeout to prevent hang)
3952        let left = self.calculate_left();
3953        let _ = tokio::time::timeout(
3954            std::time::Duration::from_secs(3),
3955            self.tracker_manager
3956                .announce_stopped(self.uploaded, self.downloaded, left),
3957        )
3958        .await;
3959
3960        // Non-blocking peer shutdown — peers may already be dead or channels full
3961        for peer in self.peers.values() {
3962            let _ = peer.cmd_tx.try_send(PeerCommand::Shutdown);
3963        }
3964    }
3965
3966    // ----- Event handlers -----
3967
3968    pub(crate) async fn handle_piece_data(
3969        &mut self,
3970        peer_addr: SocketAddr,
3971        index: u32,
3972        begin: u32,
3973        data: Bytes,
3974    ) {
3975        // Skip duplicate blocks — in end-game mode or after timeout re-requests,
3976        // the same block may arrive from multiple peers. Writing it to the store
3977        // buffer would overwrite valid data that's pending verification.
3978        if let Some(ref ct) = self.chunk_tracker
3979            && ct.has_chunk(index, begin)
3980        {
3981            self.total_download += data.len() as u64 + 13;
3982            // Remove from pending_requests to free pipeline slots. Without this,
3983            // the peer accumulates phantom entries from already-verified pieces
3984            // and eventually has zero available pipeline slots — permanent stall.
3985            if let Some(peer) = self.peers.get_mut(&peer_addr) {
3986                peer.pending_requests.remove(index, begin);
3987            }
3988            // Remove from end-game tracker so pick_block won't return this
3989            // block again. The normal path calls block_received which does
3990            // this, but we skip that path for duplicates.
3991            if self.end_game.is_active() {
3992                self.end_game.block_received(index, begin, peer_addr);
3993            }
3994            // M75: Permit already returned by peer task on Piece receipt
3995            return;
3996        }
3997
3998        let data_len = data.len();
3999
4000        // M100: Deferred write via per-torrent writer task.
4001        if let Some(ref disk) = self.disk {
4002            disk.write_block_deferred(index, begin, data);
4003        }
4004
4005        self.downloaded += data_len as u64;
4006        self.total_download += data_len as u64 + 13; // payload + message header
4007        self.last_download = now_unix();
4008        self.need_save_resume = true;
4009
4010        // M93: Track piece ownership (actor learns about peer's CAS reservation via chunk arrival)
4011        if let Some(slab_idx) = self.peer_slab.slot_of(&peer_addr)
4012            && self.piece_owner.get(index as usize) == Some(&None)
4013        {
4014            self.piece_owner[index as usize] = Some(slab_idx);
4015            // M149: Track when piece started downloading
4016            if self.inflight_started.get(index as usize) == Some(&None) {
4017                self.inflight_started[index as usize] = Some(Instant::now());
4018            }
4019            // M103: Add to steal queue if piece has unrequested blocks
4020            if let (Some(sc), Some(bm)) = (&self.steal_candidates, &self.block_maps)
4021                && let Some(lengths) = &self.lengths
4022            {
4023                let total_blocks = lengths.chunks_in_piece(index);
4024                if bm.next_unrequested(index, total_blocks).is_some() {
4025                    sc.push(index);
4026                }
4027            }
4028        }
4029
4030        // Smart banning: track which peers contribute to each piece
4031        self.piece_contributors
4032            .entry(index)
4033            .or_default()
4034            .insert(peer_addr.ip());
4035
4036        let now = std::time::Instant::now();
4037        if let Some(peer) = self.peers.get_mut(&peer_addr) {
4038            peer.pending_requests.remove(index, begin);
4039            peer.download_bytes_window += data_len as u64;
4040            peer.download_bytes_total += data_len as u64;
4041            peer.pipeline
4042                .block_received(index, begin, data_len as u32, now);
4043            peer.last_data_received = Some(now);
4044            // Clear snub if snubbed
4045            if peer.snubbed {
4046                peer.snubbed = false;
4047            }
4048        }
4049        // M137: Backoff is now automatically reset by mark_live() in PeerStates.
4050
4051        // End-game: cancel this block on all other peers. The 200ms end-game
4052        // refill tick will re-stock freed peers — no reactive cascade needed.
4053        if self.end_game.is_active() {
4054            let cancels = self.end_game.block_received(index, begin, peer_addr);
4055            for (cancel_addr, ci, cb, cl) in cancels {
4056                if let Some(cancel_peer) = self.peers.get_mut(&cancel_addr) {
4057                    let _ = cancel_peer.cmd_tx.try_send(PeerCommand::Cancel {
4058                        index: ci,
4059                        begin: cb,
4060                        length: cl,
4061                    });
4062                    cancel_peer.pending_requests.remove(ci, cb);
4063                }
4064            }
4065        }
4066
4067        // Track chunk completion
4068        let piece_complete = if let Some(ref mut ct) = self.chunk_tracker {
4069            ct.chunk_received(index, begin)
4070        } else {
4071            false
4072        };
4073
4074        if piece_complete && !self.pending_verify.contains(&index) {
4075            // M44/M118: Predictive piece announce — broadcast Have before verification
4076            if self.config.predictive_piece_announce_ms > 0
4077                && !self.predictive_have_sent.contains(&index)
4078            {
4079                self.predictive_have_sent.insert(index);
4080                let _ = self.have_broadcast_tx.send(index);
4081            }
4082
4083            // M100: Flush deferred writes before verification — ensures all
4084            // blocks are on disk so read_piece() sees complete data.
4085            if let Some(ref disk) = self.disk {
4086                disk.flush_piece_writes(index).await;
4087            }
4088
4089            match self.version {
4090                irontide_core::TorrentVersion::V1Only => {
4091                    // Async: fire-and-forget, result via verify_result_rx
4092                    if let Some(ref disk) = self.disk
4093                        && let Some(expected) = self
4094                            .meta
4095                            .as_ref()
4096                            .and_then(|m| m.info.piece_hash(index as usize))
4097                    {
4098                        self.pending_verify.insert(index);
4099                        let generation = self
4100                            .piece_generations
4101                            .get(index as usize)
4102                            .copied()
4103                            .unwrap_or(0);
4104                        disk.enqueue_verify(index, expected, generation, &self.verify_result_tx);
4105                    }
4106                }
4107                irontide_core::TorrentVersion::V2Only => {
4108                    // Blocking: needs mutable hash_picker for Merkle tree
4109                    self.verify_and_mark_piece_v2(index).await;
4110                }
4111                irontide_core::TorrentVersion::Hybrid => {
4112                    // Blocking: needs both v1+v2 decision matrix
4113                    self.verify_and_mark_piece_hybrid(index).await;
4114                }
4115            }
4116        }
4117
4118        // M75: Permit already returned by peer task on Piece receipt.
4119        // End-game dispatch still happens here.
4120        if self.end_game.is_active() {
4121            self.request_end_game_block(peer_addr).await;
4122        }
4123    }
4124
4125    /// M92: Process a batch of block completions from a single peer.
4126    /// Iterates blocks, calling `process_block_completion()` for each.
4127    /// Piece verifications are triggered inline as pieces complete
4128    /// (same as the former per-block path).
4129    pub(crate) async fn handle_piece_blocks_batch(
4130        &mut self,
4131        peer_addr: SocketAddr,
4132        blocks: Vec<crate::types::BlockEntry>,
4133    ) {
4134        for block in &blocks {
4135            self.process_block_completion(peer_addr, block.index, block.begin, block.length)
4136                .await;
4137        }
4138    }
4139
4140    fn handle_open_file(
4141        &mut self,
4142        file_index: usize,
4143    ) -> crate::Result<crate::streaming::FileStreamHandle> {
4144        let meta = self
4145            .meta
4146            .as_ref()
4147            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4148        let files = meta.info.files();
4149        if file_index >= files.len() {
4150            return Err(crate::Error::InvalidFileIndex {
4151                index: file_index,
4152                count: files.len(),
4153            });
4154        }
4155        if self.file_priorities.get(file_index).copied() == Some(FilePriority::Skip) {
4156            return Err(crate::Error::FileSkipped { index: file_index });
4157        }
4158
4159        let lengths = self
4160            .lengths
4161            .as_ref()
4162            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4163        let disk = self
4164            .disk
4165            .as_ref()
4166            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4167
4168        // Compute file offset within torrent data
4169        let mut file_offset = 0u64;
4170        for f in &files[..file_index] {
4171            file_offset += f.length;
4172        }
4173        let file_length = files[file_index].length;
4174
4175        let (cursor_tx, cursor_rx) = tokio::sync::watch::channel(0u64);
4176
4177        let permit = self
4178            .stream_read_semaphore
4179            .clone()
4180            .try_acquire_owned()
4181            .map_err(|_| crate::Error::Connection("too many concurrent stream readers".into()))?;
4182
4183        // Add streaming cursor for the actor to track
4184        self.streaming_cursors
4185            .push(crate::streaming::StreamingCursor {
4186                file_index,
4187                file_offset,
4188                cursor_piece: (file_offset / lengths.piece_length()) as u32,
4189                readahead_pieces: self.config.readahead_pieces,
4190                cursor_rx,
4191            });
4192
4193        Ok(crate::streaming::FileStreamHandle {
4194            disk: disk.clone(),
4195            lengths: lengths.clone(),
4196            file_index,
4197            file_offset,
4198            file_length,
4199            cursor_tx,
4200            piece_ready_rx: self.piece_ready_tx.subscribe(),
4201            have: self.have_watch_rx.clone(),
4202            read_permit: permit,
4203        })
4204    }
4205
4206    /// M44: Suggest cached pieces to connected peers (BEP 6).
4207    async fn suggest_cached_pieces(&mut self) {
4208        if !self.config.suggest_mode {
4209            return;
4210        }
4211        let disk = match self.disk {
4212            Some(ref d) => d.clone(),
4213            None => return,
4214        };
4215        let cached = disk.cached_pieces().await;
4216        if cached.is_empty() {
4217            return;
4218        }
4219        let max_suggest = self.config.max_suggest_pieces;
4220        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4221        for peer_addr in peer_addrs {
4222            let already_suggested = self.suggested_to_peers.entry(peer_addr).or_default();
4223            let peer_has_piece = |piece: u32| -> bool {
4224                self.peers
4225                    .get(&peer_addr)
4226                    .is_some_and(|p| p.bitfield.get(piece))
4227            };
4228            let mut sent = 0;
4229            for &piece in &cached {
4230                if sent >= max_suggest {
4231                    break;
4232                }
4233                if peer_has_piece(piece) {
4234                    continue;
4235                }
4236                if already_suggested.contains(&piece) {
4237                    continue;
4238                }
4239                if let Some(peer) = self.peers.get(&peer_addr) {
4240                    let _ = peer.cmd_tx.try_send(PeerCommand::SuggestPiece(piece));
4241                    already_suggested.insert(piece);
4242                    sent += 1;
4243                }
4244            }
4245        }
4246    }
4247
4248    /// M147: Handle pre-resolved metadata from the background resolver.
4249    ///
4250    /// If the `TorrentActor` is still in `FetchingMetadata` state, feed the
4251    /// info bytes through `MetadataDownloader` and call `try_assemble_metadata()`.
4252    /// If already past that state (actor resolved first), silently ignore.
4253    async fn handle_pre_resolved_metadata(&mut self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
4254        // Only act if still fetching metadata — actor may have resolved first.
4255        if self.state != TorrentState::FetchingMetadata {
4256            debug!(
4257                info_hash = %self.info_hash,
4258                state = ?self.state,
4259                "ignoring pre-resolved metadata: already past FetchingMetadata"
4260            );
4261            return;
4262        }
4263
4264        debug!(
4265            info_hash = %self.info_hash,
4266            info_bytes_len = info_bytes.len(),
4267            num_peers = peers.len(),
4268            "received pre-resolved metadata from background resolver"
4269        );
4270
4271        // Feed the complete info bytes to the MetadataDownloader.
4272        if let Some(ref mut dl) = self.metadata_downloader {
4273            // Set total size so the downloader knows the expected piece count.
4274            dl.set_total_size(info_bytes.len() as u64);
4275
4276            // Feed as a single piece (piece 0) containing the full info dict.
4277            // For metadata smaller than 16 KiB this is a single piece.
4278            // For larger metadata, feed each 16 KiB chunk as a separate piece.
4279            let piece_size: usize = 16384;
4280            let num_pieces = info_bytes.len().div_ceil(piece_size);
4281            for i in 0..num_pieces {
4282                let start = i * piece_size;
4283                let end = (start + piece_size).min(info_bytes.len());
4284                let data = bytes::Bytes::copy_from_slice(&info_bytes[start..end]);
4285                dl.piece_received(i as u32, data);
4286            }
4287        }
4288
4289        // Attempt assembly — this will transition to Downloading if
4290        // the info_hash validates.
4291        self.try_assemble_metadata().await;
4292
4293        // Pre-seed discovered peers into the pipeline.
4294        if !peers.is_empty() {
4295            self.handle_add_peers(peers, crate::peer_state::PeerSource::Dht);
4296        }
4297    }
4298
4299    pub(crate) async fn try_assemble_metadata(&mut self) {
4300        let assembled = if let Some(ref dl) = self.metadata_downloader {
4301            dl.assemble_and_verify()
4302        } else {
4303            return;
4304        };
4305
4306        match assembled {
4307            Ok(info_bytes) => {
4308                // Build torrent bytes wrapping the raw info dict into a minimal torrent
4309                // We need to parse it as a full torrent. The info_bytes is the raw bencoded
4310                // info dict. We'll build a minimal torrent around it.
4311                // Actually, torrent_from_bytes expects a full torrent dict.
4312                // Let's build one:
4313                let mut torrent_bytes = b"d4:info".to_vec();
4314                torrent_bytes.extend_from_slice(&info_bytes);
4315                torrent_bytes.push(b'e');
4316
4317                match torrent_from_bytes(&torrent_bytes) {
4318                    Ok(meta) => {
4319                        let num_pieces = meta.info.num_pieces() as u32;
4320                        let lengths = Lengths::new(
4321                            meta.info.total_length(),
4322                            meta.info.piece_length,
4323                            DEFAULT_CHUNK_SIZE,
4324                        );
4325
4326                        // Create filesystem storage now that we know the file layout
4327                        let files = meta.info.files();
4328                        let file_paths: Vec<std::path::PathBuf> = files
4329                            .iter()
4330                            .map(|f| f.path.iter().collect::<std::path::PathBuf>())
4331                            .collect();
4332                        let file_lengths_vec: Vec<u64> = files.iter().map(|f| f.length).collect();
4333                        let prealloc_mode = self.config.preallocate_mode.unwrap_or_else(|| {
4334                            irontide_storage::PreallocateMode::from(
4335                                self.config.storage_mode == irontide_core::StorageMode::Full,
4336                            )
4337                        });
4338                        let storage: Arc<dyn TorrentStorage> =
4339                            match irontide_storage::FilesystemStorage::new(
4340                                &self.config.download_dir,
4341                                file_paths,
4342                                file_lengths_vec,
4343                                lengths.clone(),
4344                                None,
4345                                prealloc_mode,
4346                                self.config.filesystem_direct_io,
4347                            ) {
4348                                Ok(s) => Arc::new(s),
4349                                Err(e) => {
4350                                    warn!(
4351                                        "failed to create filesystem storage: {e}, falling back to memory"
4352                                    );
4353                                    Arc::new(MemoryStorage::new(lengths.clone()))
4354                                }
4355                            };
4356                        let mut disk_handle = self
4357                            .disk_manager
4358                            .register_torrent(self.info_hash, storage)
4359                            .await;
4360
4361                        self.chunk_tracker = Some(ChunkTracker::new(lengths.clone()));
4362                        self.lengths = Some(lengths);
4363                        self.num_pieces = num_pieces;
4364                        // M96: Initialize real generation counters + hash result channel
4365                        self.piece_generations = vec![0u64; num_pieces as usize];
4366                        let (hash_tx, hash_rx) = tokio::sync::mpsc::channel(64);
4367                        self.hash_result_tx = hash_tx;
4368                        self.hash_result_rx = hash_rx;
4369                        // M96: Wire hash pool into disk handle (version check deferred
4370                        // until after metadata detection below sets self.version)
4371                        if let Some(ref pool) = self.hash_pool_ref {
4372                            disk_handle.set_hash_pool(pool.clone());
4373                            disk_handle.set_hash_result_tx(self.hash_result_tx.clone());
4374                        }
4375                        self.disk = Some(disk_handle);
4376                        // Update all connected peer tasks so they can validate
4377                        // incoming Bitfield messages with the correct piece count.
4378                        for peer in self.peers.values() {
4379                            let _ = peer
4380                                .cmd_tx
4381                                .try_send(PeerCommand::UpdateNumPieces(num_pieces));
4382                        }
4383                        let file_lengths: Vec<u64> =
4384                            meta.info.files().iter().map(|f| f.length).collect();
4385                        let mut meta = meta;
4386                        meta.info_bytes = Some(Bytes::from(info_bytes));
4387                        self.meta = Some(meta);
4388
4389                        // M116: Populate cached file info for zero-alloc completion checks.
4390                        if let (Some(meta), Some(lengths)) = (&self.meta, &self.lengths) {
4391                            self.cached_files = Some(build_cached_file_info(meta, lengths));
4392                        }
4393
4394                        self.file_priorities = vec![FilePriority::Normal; file_lengths.len()];
4395
4396                        // BEP 53: apply magnet so= file selection
4397                        if let Some(ref selections) = self.magnet_selected_files {
4398                            self.file_priorities = irontide_core::FileSelection::to_priorities(
4399                                selections,
4400                                file_lengths.len(),
4401                            );
4402                            self.magnet_selected_files = None;
4403                        }
4404
4405                        self.wanted_pieces = crate::piece_selector::build_wanted_pieces(
4406                            &self.file_priorities,
4407                            &file_lengths,
4408                            self.lengths.as_ref().unwrap(),
4409                        );
4410                        if self.config.share_mode {
4411                            self.transition_state(TorrentState::Sharing);
4412                        } else {
4413                            self.transition_state(TorrentState::Downloading);
4414                        }
4415                        self.metadata_downloader = None;
4416
4417                        // Populate tracker manager with newly parsed metadata
4418                        if let Some(ref meta) = self.meta {
4419                            self.tracker_manager
4420                                .set_metadata_filtered(meta, self.config.url_security);
4421                        }
4422
4423                        // Detect hybrid/v2 from metadata and update dual-swarm state
4424                        // (Gap 1 & 2: propagate info_hashes to tracker + DHT after magnet resolves)
4425                        if let Ok(detected) = irontide_core::torrent_from_bytes_any(&torrent_bytes)
4426                        {
4427                            let new_version = detected.version();
4428                            if new_version != irontide_core::TorrentVersion::V1Only {
4429                                let new_hashes = detected.info_hashes();
4430                                self.version = new_version;
4431                                self.info_hashes = new_hashes.clone();
4432                                self.tracker_manager.set_info_hashes(new_hashes.clone());
4433                                if let Some(v2_meta) = detected.as_v2() {
4434                                    self.meta_v2 = Some(v2_meta.clone());
4435                                }
4436                                // Start v2 DHT lookups for hybrid torrents
4437                                if new_hashes.is_hybrid()
4438                                    && let Some(v2) = new_hashes.v2
4439                                {
4440                                    let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
4441                                    if v2_as_v1 != self.info_hash {
4442                                        if self.dht_v2_peers_rx.is_none()
4443                                            && let Some(dht) = self.current_dht()
4444                                            && let Ok(rx) = dht.get_peers(v2_as_v1).await
4445                                        {
4446                                            self.dht_v2_peers_rx = Some(rx);
4447                                        }
4448                                        if self.dht_v6_v2_peers_rx.is_none()
4449                                            && self.dht_v6_empty_count < 30
4450                                            && self.should_retry_v6()
4451                                            && let Some(dht6) = self.current_dht_v6()
4452                                            && let Ok(rx) = dht6.get_peers(v2_as_v1).await
4453                                        {
4454                                            self.dht_v6_last_retry =
4455                                                Some(std::time::Instant::now());
4456                                            self.dht_v6_v2_peers_rx = Some(rx);
4457                                        }
4458                                    }
4459                                }
4460                            }
4461                        }
4462
4463                        let name = self
4464                            .meta
4465                            .as_ref()
4466                            .map(|m| m.info.name.clone())
4467                            .unwrap_or_default();
4468                        post_alert(
4469                            &self.alert_tx,
4470                            &self.alert_mask,
4471                            AlertKind::MetadataReceived {
4472                                info_hash: self.info_hash,
4473                                name,
4474                            },
4475                        );
4476                        info!("metadata assembled, switching to Downloading");
4477
4478                        // M93: Initialize lock-free piece states after metadata
4479                        if let Some(ct) = &self.chunk_tracker {
4480                            let atomic_states = Arc::new(AtomicPieceStates::new(
4481                                self.num_pieces,
4482                                ct.bitfield(),
4483                                &self.wanted_pieces,
4484                            ));
4485                            self.atomic_states = Some(Arc::clone(&atomic_states));
4486                            self.piece_owner = vec![None; self.num_pieces as usize];
4487                            // M149: Initialize inflight tracking
4488                            self.inflight_started = vec![None; self.num_pieces as usize];
4489                            self.max_in_flight = self.config.max_in_flight_pieces;
4490
4491                            // M103: Initialize block stealing infrastructure
4492                            if self.config.use_block_stealing {
4493                                if let Some(ref lengths) = self.lengths {
4494                                    self.block_maps =
4495                                        Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
4496                                }
4497                                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
4498                            }
4499                            // M120: Per-piece write guards
4500                            self.piece_write_guards = Some(Arc::new(
4501                                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
4502                            ));
4503
4504                            // M187: Init direct-acquire dispatch state.
4505                            self.piece_tracker = Some(PieceTracker::new(
4506                                self.num_pieces,
4507                                ct.bitfield(),
4508                                &self.wanted_pieces,
4509                            ));
4510                            if let Some(ref cached) = self.cached_files {
4511                                let file_piece_ranges: Vec<(u32, u32)> = cached
4512                                    .entries
4513                                    .iter()
4514                                    .map(|e| (e.first_piece, e.last_piece))
4515                                    .collect();
4516                                let om = Arc::new(PieceOrderMap::build(
4517                                    &self.file_priorities,
4518                                    &file_piece_ranges,
4519                                    self.num_pieces,
4520                                    0,
4521                                ));
4522                                self.order_map_tx.send_replace(om);
4523                            }
4524
4525                            let notify = Arc::new(tokio::sync::Notify::new());
4526                            self.reservation_notify = Some(notify);
4527                        }
4528
4529                        // Start web seeds now that we have metadata
4530                        self.spawn_web_seeds();
4531                        self.assign_pieces_to_web_seeds();
4532
4533                        // Kick-start piece requesting for all peers that connected during
4534                        // metadata phase. Send StartRequesting to all connected peers.
4535                        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4536                        info!(
4537                            connected_peers = peer_addrs.len(),
4538                            "kick-starting piece requests for pre-connected peers"
4539                        );
4540                        for addr in peer_addrs {
4541                            let has_bitfield =
4542                                self.peers.get(&addr).map_or(0, |p| p.bitfield.count_ones());
4543                            let is_choking = self.peers.get(&addr).is_none_or(|p| p.peer_choking);
4544                            debug!(%addr, has_bitfield, is_choking, "post-metadata peer state");
4545                            self.maybe_express_interest(addr).await;
4546                            if let Some(peer) = self.peers.get(&addr)
4547                                && peer.bitfield.count_ones() > 0
4548                            {
4549                                let _slot = self.peer_slab.insert(addr);
4550                            }
4551                        }
4552                        self.recalc_max_in_flight();
4553                        // M93: Inform all connected peers about lock-free dispatch state.
4554                        // M159: Skip while user seed mode is active — we are currently
4555                        // not scheduling any new block requests.
4556                        if !self.user_seed_mode
4557                            && let Some(notify) = &self.reservation_notify
4558                            && let Some(ref lengths) = self.lengths
4559                        {
4560                            for peer in self.peers.values() {
4561                                let _ = peer.cmd_tx.try_send(PeerCommand::StartRequesting {
4562                                    piece_notify: Arc::clone(notify),
4563                                    disk_handle: self.disk.clone(),
4564                                    write_error_tx: self.write_error_tx.clone(),
4565                                    lengths: lengths.clone(),
4566                                });
4567                            }
4568                        }
4569                    }
4570                    Err(e) => {
4571                        warn!("failed to parse assembled metadata: {e}");
4572                        post_alert(
4573                            &self.alert_tx,
4574                            &self.alert_mask,
4575                            AlertKind::MetadataFailed {
4576                                info_hash: self.info_hash,
4577                            },
4578                        );
4579                    }
4580                }
4581            }
4582            Err(e) => {
4583                warn!("metadata assembly failed: {e}");
4584                post_alert(
4585                    &self.alert_tx,
4586                    &self.alert_mask,
4587                    AlertKind::MetadataFailed {
4588                        info_hash: self.info_hash,
4589                    },
4590                );
4591            }
4592        }
4593    }
4594
4595    // ----- Web seeding (M22) -----
4596
4597    fn spawn_web_seeds(&mut self) {
4598        if !self.config.enable_web_seed {
4599            return;
4600        }
4601        let Some(meta) = &self.meta else { return };
4602        let lengths = match &self.lengths {
4603            Some(l) => l.clone(),
4604            None => return,
4605        };
4606
4607        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
4608        let file_map = irontide_storage::FileMap::new(file_lengths, lengths.clone());
4609
4610        // BEP 19 (GetRight) web seeds
4611        for url in &meta.url_list {
4612            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4613                continue;
4614            }
4615            if self.web_seeds.len() >= self.config.max_web_seeds {
4616                break;
4617            }
4618
4619            // Security validation
4620            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4621                warn!(%url, %e, "web seed URL rejected by security policy");
4622                continue;
4623            }
4624
4625            let url_builder = if meta.info.length.is_some() {
4626                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone())
4627            } else {
4628                let file_paths: Vec<String> = meta
4629                    .info
4630                    .files()
4631                    .iter()
4632                    .map(|f| f.path[1..].join("/")) // skip torrent name prefix
4633                    .collect();
4634                crate::web_seed::WebSeedUrlBuilder::multi(
4635                    url.clone(),
4636                    meta.info.name.clone(),
4637                    file_paths,
4638                )
4639            };
4640
4641            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4642            let initial_downloaded = self
4643                .web_seed_stats
4644                .get(url)
4645                .map_or(0, |s| s.downloaded_bytes);
4646            let task = crate::web_seed::WebSeedTask::new(
4647                url.clone(),
4648                crate::web_seed::WebSeedMode::GetRight,
4649                url_builder,
4650                lengths.clone(),
4651                file_map.clone(),
4652                self.info_hash,
4653                cmd_rx,
4654                self.event_tx.clone(),
4655                self.config.url_security,
4656                self.config.web_seed_progress_throttle_ms,
4657                initial_downloaded,
4658                self.config.web_seed_retry_base_secs,
4659                self.config.web_seed_retry_factor,
4660                self.config.web_seed_retry_cap_secs,
4661                self.config.web_seed_max_failures,
4662            );
4663            tokio::spawn(task.run());
4664            self.web_seeds.insert(url.clone(), cmd_tx);
4665            debug!(url, "spawned BEP 19 web seed");
4666        }
4667
4668        // BEP 17 (Hoffman) HTTP seeds
4669        for url in &meta.httpseeds {
4670            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4671                continue;
4672            }
4673            if self.web_seeds.len() >= self.config.max_web_seeds {
4674                break;
4675            }
4676
4677            // Security validation
4678            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4679                warn!(%url, %e, "web seed URL rejected by security policy");
4680                continue;
4681            }
4682
4683            // BEP 17 doesn't use URL builder for per-file paths; it sends parameterized URLs
4684            let url_builder =
4685                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone());
4686
4687            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4688            let initial_downloaded = self
4689                .web_seed_stats
4690                .get(url)
4691                .map_or(0, |s| s.downloaded_bytes);
4692            let task = crate::web_seed::WebSeedTask::new(
4693                url.clone(),
4694                crate::web_seed::WebSeedMode::Hoffman,
4695                url_builder,
4696                lengths.clone(),
4697                file_map.clone(),
4698                self.info_hash,
4699                cmd_rx,
4700                self.event_tx.clone(),
4701                self.config.url_security,
4702                self.config.web_seed_progress_throttle_ms,
4703                initial_downloaded,
4704                self.config.web_seed_retry_base_secs,
4705                self.config.web_seed_retry_factor,
4706                self.config.web_seed_retry_cap_secs,
4707                self.config.web_seed_max_failures,
4708            );
4709            tokio::spawn(task.run());
4710            self.web_seeds.insert(url.clone(), cmd_tx);
4711            debug!(url, "spawned BEP 17 web seed");
4712        }
4713    }
4714
4715    pub(crate) fn assign_pieces_to_web_seeds(&mut self) {
4716        if self.state != TorrentState::Downloading || self.end_game.is_active() {
4717            return;
4718        }
4719
4720        // Collect idle web seed URLs (not currently downloading a piece)
4721        let active_urls: HashSet<&String> = self.web_seed_in_flight.values().collect();
4722        let idle_urls: Vec<String> = self
4723            .web_seeds
4724            .keys()
4725            .filter(|u| !active_urls.contains(u))
4726            .cloned()
4727            .collect();
4728
4729        let Some(ct) = &self.chunk_tracker else {
4730            return;
4731        };
4732
4733        for url in idle_urls {
4734            // Find lowest-index piece that is: not verified, not reserved by a peer,
4735            // not in web_seed_in_flight, and wanted.
4736            let piece = (0..self.num_pieces).find(|&i| {
4737                !ct.has_piece(i)
4738                    && !self
4739                        .piece_owner
4740                        .get(i as usize)
4741                        .is_some_and(std::option::Option::is_some)
4742                    && !self.web_seed_in_flight.contains_key(&i)
4743                    && self.wanted_pieces.get(i)
4744            });
4745
4746            if let Some(piece) = piece
4747                && let Some(cmd_tx) = self.web_seeds.get(&url)
4748            {
4749                let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::FetchPiece(piece));
4750                self.web_seed_in_flight.insert(piece, url);
4751            }
4752        }
4753    }
4754
4755    pub(crate) async fn handle_web_seed_piece_data(
4756        &mut self,
4757        url: String,
4758        index: u32,
4759        data: Bytes,
4760    ) {
4761        self.web_seed_in_flight.remove(&index);
4762
4763        // If peer already completed this piece, discard
4764        if let Some(ref ct) = self.chunk_tracker
4765            && ct.has_piece(index)
4766        {
4767            self.assign_pieces_to_web_seeds();
4768            return;
4769        }
4770
4771        // Write entire piece to disk at offset 0
4772        if let Some(ref disk) = self.disk
4773            && let Err(e) = disk
4774                .write_chunk(index, 0, data.clone(), DiskJobFlags::FLUSH_PIECE)
4775                .await
4776        {
4777            warn!(index, "web seed: failed to write piece: {e}");
4778            self.assign_pieces_to_web_seeds();
4779            return;
4780        }
4781
4782        // Mark all chunks as received
4783        if let Some(ref mut ct) = self.chunk_tracker
4784            && let Some(ref lengths) = self.lengths
4785        {
4786            let num_chunks = lengths.chunks_in_piece(index);
4787            for chunk_idx in 0..num_chunks {
4788                if let Some((begin, _len)) = lengths.chunk_info(index, chunk_idx) {
4789                    ct.chunk_received(index, begin);
4790                }
4791            }
4792        }
4793
4794        self.downloaded += data.len() as u64;
4795        self.total_download += data.len() as u64 + 13; // payload + message header
4796        self.last_download = now_unix();
4797        self.need_save_resume = true;
4798
4799        // Verify the piece hash
4800        self.verify_and_mark_piece(index).await;
4801
4802        // If hash failed, ban this web seed (BEP 19 spec)
4803        if let Some(ref ct) = self.chunk_tracker
4804            && !ct.has_piece(index)
4805        {
4806            self.ban_web_seed(&url);
4807            return;
4808        }
4809
4810        self.assign_pieces_to_web_seeds();
4811    }
4812
4813    pub(crate) fn handle_web_seed_error(&mut self, url: &str, piece: u32, message: &str) {
4814        self.web_seed_in_flight.remove(&piece);
4815        warn!(%url, piece, %message, "web seed error");
4816        self.assign_pieces_to_web_seeds();
4817    }
4818
4819    /// M178: Update per-URL `WebSeedStats` from a `WebSeedProgress` event.
4820    ///
4821    /// State machine: Idle → Active on first success; Active → Errored on
4822    /// failure; Errored → Active on recovery (`last_error` PERSISTS through
4823    /// recovery per Issue 2.2 / D-eng-8). `consecutive_failures` increments
4824    /// monotonically within a failure run and resets to zero on success.
4825    /// `last_attempt_unix_secs` updates on every event regardless of outcome.
4826    pub(crate) fn handle_web_seed_progress(
4827        &mut self,
4828        url: &str,
4829        bytes: u64,
4830        rate_bps: u64,
4831        error: Option<String>,
4832    ) {
4833        let now_unix = std::time::SystemTime::now()
4834            .duration_since(std::time::UNIX_EPOCH)
4835            .map_or(0, |d| d.as_secs());
4836        let entry = self
4837            .web_seed_stats
4838            .entry(url.to_owned())
4839            .or_insert_with(|| irontide_core::WebSeedStats {
4840                url: url.to_owned(),
4841                ..Default::default()
4842            });
4843        entry.downloaded_bytes = bytes;
4844        entry.last_rate_bps = rate_bps;
4845        entry.last_attempt_unix_secs = now_unix;
4846        if let Some(msg) = error {
4847            entry.state = irontide_core::WebSeedState::Errored;
4848            entry.last_error = Some(msg);
4849            entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
4850            // M186: Populate next_retry_unix_secs during backoff
4851            let attempt = entry.consecutive_failures.saturating_sub(1);
4852            let secs = self
4853                .config
4854                .web_seed_retry_base_secs
4855                .saturating_mul(self.config.web_seed_retry_factor.saturating_pow(attempt))
4856                .min(self.config.web_seed_retry_cap_secs);
4857            entry.next_retry_unix_secs = Some(now_unix + secs);
4858        } else {
4859            entry.state = irontide_core::WebSeedState::Active;
4860            entry.consecutive_failures = 0;
4861            entry.next_retry_unix_secs = None;
4862        }
4863        self.need_save_resume = true;
4864    }
4865
4866    pub(crate) fn ban_web_seed(&mut self, url: &str) {
4867        warn!(%url, "banning web seed due to hash failure");
4868        self.banned_web_seeds.insert(url.to_owned());
4869
4870        // Send shutdown to the task
4871        if let Some(cmd_tx) = self.web_seeds.remove(url) {
4872            let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::Shutdown);
4873        }
4874
4875        // Remove all in-flight pieces for this URL
4876        self.web_seed_in_flight.retain(|_, v| v != url);
4877
4878        post_alert(
4879            &self.alert_tx,
4880            &self.alert_mask,
4881            AlertKind::WebSeedBanned {
4882                info_hash: self.info_hash,
4883                url: url.to_owned(),
4884            },
4885        );
4886    }
4887
4888    async fn shutdown_web_seeds(&mut self) {
4889        for (_, cmd_tx) in self.web_seeds.drain() {
4890            let _ = cmd_tx.send(crate::web_seed::WebSeedCommand::Shutdown).await;
4891        }
4892        self.web_seed_in_flight.clear();
4893    }
4894
4895    /// Rebuild the cached peer rates map from current peer state.
4896    fn refresh_peer_rates(&mut self) {
4897        self.cached_peer_rates.clear();
4898        self.cached_peer_rates.reserve(self.peers.len());
4899        for (&addr, p) in &self.peers {
4900            self.cached_peer_rates.insert(addr, p.pipeline.ewma_rate());
4901        }
4902    }
4903
4904    // ----- Choking -----
4905
4906    fn update_peer_rates(&mut self) {
4907        for peer in self.peers.values_mut() {
4908            peer.download_rate = peer.download_bytes_window / 2;
4909            peer.upload_rate = peer.upload_bytes_window / 2;
4910            peer.download_bytes_window = 0;
4911            peer.upload_bytes_window = 0;
4912        }
4913
4914        // Track peak download rate for peer turnover cutoff
4915        let aggregate_download: u64 = self.peers.values().map(|p| p.download_rate).sum();
4916        if aggregate_download > self.peak_download_rate {
4917            self.peak_download_rate = aggregate_download;
4918        }
4919    }
4920
4921    async fn run_choker(&mut self) {
4922        let peer_infos: Vec<ChokerPeerInfo> = self
4923            .peers
4924            .values()
4925            .map(|p| ChokerPeerInfo {
4926                addr: p.addr,
4927                download_rate: p.download_rate,
4928                upload_rate: p.upload_rate,
4929                interested: p.peer_interested,
4930                upload_only: p.upload_only,
4931                is_seed: p.upload_only
4932                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
4933            })
4934            .collect();
4935
4936        let decision = self.choker.decide(&peer_infos);
4937
4938        for addr in &decision.to_unchoke {
4939            if let Some(peer) = self.peers.get_mut(addr)
4940                && peer.am_choking
4941            {
4942                peer.am_choking = false;
4943                // Track unchoke window for fairness measurement.
4944                if peer.am_unchoke_started_at.is_none() {
4945                    peer.am_unchoke_started_at = Some(Instant::now());
4946                }
4947                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(false));
4948            }
4949        }
4950
4951        for addr in &decision.to_choke {
4952            if let Some(peer) = self.peers.get_mut(addr)
4953                && !peer.am_choking
4954            {
4955                if peer.supports_fast {
4956                    let pending: Vec<(u32, u32, u32)> = peer.incoming_requests.drain(..).collect();
4957                    for (index, begin, length) in pending {
4958                        let _ = peer.cmd_tx.try_send(PeerCommand::RejectRequest {
4959                            index,
4960                            begin,
4961                            length,
4962                        });
4963                    }
4964                }
4965                peer.am_choking = true;
4966                // Accumulate the unchoke window we just closed.
4967                if let Some(start) = peer.am_unchoke_started_at.take() {
4968                    peer.unchoke_duration_total += start.elapsed();
4969                }
4970                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(true));
4971            }
4972        }
4973
4974        // Serve any buffered requests from newly-unchoked peers
4975        self.serve_incoming_requests().await;
4976
4977        // Zombie pruning: disconnect peers with empty bitfields after 30s.
4978        // These peers consume connection slots but contribute no pieces.
4979        // Only prune during downloading — when seeding, empty-bitfield peers
4980        // are leechers we want to upload to.
4981        if self.state == TorrentState::Downloading {
4982            let zombie_threshold = Duration::from_secs(30);
4983            let zombies: Vec<SocketAddr> = self
4984                .peers
4985                .values()
4986                .filter(|p| {
4987                    p.bitfield.count_ones() == 0 && p.connected_at.elapsed() > zombie_threshold
4988                })
4989                .map(|p| p.addr)
4990                .collect();
4991
4992            for &addr in &zombies {
4993                debug!(%addr, "disconnecting zombie peer (empty bitfield after 30s)");
4994                self.disconnect_peer(addr, "zombie peer (empty bitfield)");
4995            }
4996            if !zombies.is_empty() {
4997                self.recalc_max_in_flight();
4998            }
4999        }
5000    }
5001
5002    fn rotate_optimistic(&mut self) {
5003        let peer_infos: Vec<ChokerPeerInfo> = self
5004            .peers
5005            .values()
5006            .map(|p| ChokerPeerInfo {
5007                addr: p.addr,
5008                download_rate: p.download_rate,
5009                upload_rate: p.upload_rate,
5010                interested: p.peer_interested,
5011                upload_only: p.upload_only,
5012                is_seed: p.upload_only
5013                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5014            })
5015            .collect();
5016
5017        self.choker.rotate_optimistic(&peer_infos);
5018    }
5019
5020    /// Handle an incoming I2P peer connection (M41).
5021    ///
5022    /// Assigns a synthetic `SocketAddr` (from the reserved 240.0.0.0/4 range) since
5023    /// I2P peers don't have real IP addresses, then hands the underlying TCP stream
5024    /// to `spawn_peer_from_stream`.
5025    fn handle_i2p_incoming(&mut self, stream: crate::i2p::SamStream) {
5026        if self.peers.len() >= self.effective_max_connections() {
5027            return;
5028        }
5029
5030        let synthetic_addr = self.next_i2p_synthetic_addr();
5031
5032        let remote_dest = stream.remote_destination().clone();
5033        let dest_preview = {
5034            let b64 = remote_dest.to_base64();
5035            if b64.len() >= 8 {
5036                b64[..8].to_string()
5037            } else {
5038                b64
5039            }
5040        };
5041        self.i2p_destinations.insert(synthetic_addr, remote_dest);
5042        let tcp_stream = stream.into_inner();
5043
5044        self.spawn_peer_from_stream(synthetic_addr, tcp_stream);
5045
5046        debug!(dest = %dest_preview, addr = %synthetic_addr, "accepted I2P peer");
5047    }
5048
5049    /// Add an I2P peer by destination, assigning a synthetic `SocketAddr`.
5050    #[allow(dead_code)] // Used by Task 2 (outbound I2P connects)
5051    fn add_i2p_peer(
5052        &mut self,
5053        dest: crate::i2p::I2pDestination,
5054        source: PeerSource,
5055    ) -> Option<SocketAddr> {
5056        // Dedup: check if we already track this destination
5057        if self.i2p_destinations.values().any(|d| d == &dest) {
5058            return None;
5059        }
5060        let addr = self.next_i2p_synthetic_addr();
5061        self.i2p_destinations.insert(addr, dest);
5062        // M137: Track via unified PeerStates lifecycle
5063        if let Some(ref ps) = self.peer_states {
5064            ps.add_if_not_seen(addr, source);
5065        }
5066        Some(addr)
5067    }
5068
5069    /// Generate a unique synthetic `SocketAddr` for an I2P peer.
5070    ///
5071    /// Uses addresses from 240.0.0.0/4 (reserved, never routable) to avoid
5072    /// conflicts with real peers. The counter ensures uniqueness across the
5073    /// torrent's lifetime.
5074    fn next_i2p_synthetic_addr(&mut self) -> SocketAddr {
5075        self.i2p_peer_counter = self.i2p_peer_counter.wrapping_add(1);
5076        let a = ((self.i2p_peer_counter >> 16) & 0x0F) as u8 | 0xF0;
5077        let b = ((self.i2p_peer_counter >> 8) & 0xFF) as u8;
5078        let c = (self.i2p_peer_counter & 0xFF) as u8;
5079        SocketAddr::new(
5080            std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, 1)),
5081            (self.i2p_peer_counter & 0xFFFF) as u16,
5082        )
5083    }
5084}
5085
5086/// Check whether a `SocketAddr` uses a synthetic I2P address (240.0.0.0/4 range).
5087pub(crate) fn is_i2p_synthetic_addr(addr: &SocketAddr) -> bool {
5088    match addr {
5089        SocketAddr::V4(v4) => v4.ip().octets()[0] & 0xF0 == 0xF0,
5090        SocketAddr::V6(_) => false,
5091    }
5092}
5093
5094/// Helper to accept a connection from an optional transport listener.
5095/// Returns `pending` if no listener is bound, so the `select!` branch is skipped.
5096async fn accept_incoming(
5097    listener: &mut Option<Box<dyn crate::transport::TransportListener>>,
5098) -> std::io::Result<(crate::transport::BoxedStream, SocketAddr)> {
5099    match listener {
5100        Some(l) => l.accept().await,
5101        None => std::future::pending().await,
5102    }
5103}
5104
5105/// Helper to receive an incoming I2P connection from the accept loop channel.
5106/// Returns `pending` if I2P is not enabled, so the `select!` branch is skipped.
5107async fn accept_i2p(
5108    rx: &mut Option<mpsc::Receiver<crate::i2p::SamStream>>,
5109) -> Option<crate::i2p::SamStream> {
5110    match rx {
5111        Some(rx) => rx.recv().await,
5112        None => std::future::pending().await,
5113    }
5114}
5115
5116// ============================================================================
5117// BEP 52 hash serving (M87)
5118// ============================================================================
5119
5120/// Determine what to serve for a BEP 52 hash request.
5121///
5122/// Returns `Some(hashes)` to serve, or `None` to reject.
5123/// Only serves piece-layer hashes (the layer stored in `piece_layers`).
5124/// Block-layer or other layer requests are rejected since we don't store
5125/// the full Merkle tree.
5126pub(crate) fn serve_hashes(
5127    meta_v2: Option<&irontide_core::TorrentMetaV2>,
5128    version: irontide_core::TorrentVersion,
5129    lengths: Option<&Lengths>,
5130    request: &irontide_core::HashRequest,
5131) -> Option<Vec<irontide_core::Id32>> {
5132    // Reject if v1-only or no v2 metadata
5133    let meta_v2 = match meta_v2 {
5134        Some(m) if version != irontide_core::TorrentVersion::V1Only => m,
5135        _ => return None,
5136    };
5137
5138    // Look up piece-layer hashes for the requested file root
5139    let piece_hashes = meta_v2.file_piece_hashes(&request.file_root)?;
5140
5141    // We need lengths to validate the request geometry
5142    let lengths = lengths?;
5143
5144    // Compute per-file block count from piece hashes and piece/chunk sizes.
5145    // Each piece hash covers `piece_length / chunk_size` blocks, except the
5146    // last piece which may cover fewer. For validation purposes we use the
5147    // padded count that `validate_hash_request` expects.
5148    let blocks_per_piece = (meta_v2.info.piece_length / u64::from(lengths.chunk_size())) as u32;
5149    let num_pieces = piece_hashes.len() as u32;
5150    let num_blocks = num_pieces.saturating_mul(blocks_per_piece);
5151
5152    if !irontide_core::validate_hash_request(request, num_blocks, num_pieces) {
5153        return None;
5154    }
5155
5156    // We only have piece-layer hashes. The piece layer is at
5157    // base = log2(blocks_per_piece). Reject requests for other layers.
5158    let piece_layer_base = blocks_per_piece.trailing_zeros();
5159    if request.base != piece_layer_base {
5160        return None;
5161    }
5162
5163    // Extract requested hashes from the piece layer
5164    let start = request.index as usize;
5165    let end = (start + request.count as usize).min(piece_hashes.len());
5166    let mut hashes: Vec<irontide_core::Id32> = piece_hashes[start..end].to_vec();
5167
5168    // Compute proof (uncle) hashes if requested.
5169    //
5170    // BEP 52 specifies a single subtree proof for the entire batch, not
5171    // per-leaf proofs. The receiver rebuilds the subtree root from the
5172    // base hashes itself, so we skip the first `log2(count)` levels of
5173    // the proof path (those are internal to the requested subtree) and
5174    // only send the uncle hashes above it.
5175    if request.proof_layers > 0 && !piece_hashes.is_empty() {
5176        let tree = irontide_core::MerkleTree::from_leaves(&piece_hashes);
5177        let full_proof = tree.proof_path(start);
5178        // Skip levels internal to the requested subtree
5179        let subtree_depth = if request.count > 1 {
5180            (request.count as usize)
5181                .next_power_of_two()
5182                .trailing_zeros() as usize
5183        } else {
5184            0
5185        };
5186        let available = full_proof.len().saturating_sub(subtree_depth);
5187        let proof_count = (request.proof_layers as usize).min(available);
5188        hashes.extend_from_slice(&full_proof[subtree_depth..subtree_depth + proof_count]);
5189    }
5190
5191    Some(hashes)
5192}
5193
5194// ============================================================================
5195// Test-only constructors
5196// ============================================================================
5197
5198#[cfg(test)]
5199impl TorrentActor {
5200    /// v0.173.3 (A4): Build a minimal `TorrentActor` exercising only the
5201    /// fields touched by `rebuild_availability_snapshot`.
5202    ///
5203    /// Every other field is filled with the cheapest valid placeholder
5204    /// (empty channels, zero atomics, no-op handles). The actor is **not**
5205    /// spawned via `tokio::spawn` so it has no live `run()` loop — the
5206    /// returned struct is suitable for direct method-level testing only.
5207    ///
5208    /// `num_pieces` controls the size of the pre-allocated availability
5209    /// vector and atomic-states bitmap. `throttle_ms` plumbs the v0.173.3
5210    /// throttle config into the synthetic actor's `TorrentConfig`.
5211    ///
5212    /// Must run inside a tokio runtime because `DiskManagerHandle::new`
5213    /// internally spawns its background actor.
5214    pub(crate) fn for_throttle_test(num_pieces: u32, _throttle_ms: u64) -> Self {
5215        use irontide_storage::Bitfield;
5216
5217        let config = TorrentConfig {
5218            ..TorrentConfig::default()
5219        };
5220
5221        let info_hash = Id20([0u8; 20]);
5222        let our_peer_id = Id20([0u8; 20]);
5223
5224        let (_cmd_tx, cmd_rx) = mpsc::channel(1);
5225        let (event_tx, event_rx) = mpsc::channel(1);
5226        let (write_error_tx, write_error_rx) = mpsc::channel(1);
5227        let (verify_result_tx, verify_result_rx) = mpsc::channel(1);
5228        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
5229        let (piece_ready_tx, _piece_ready_rx) = broadcast::channel(1);
5230        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(num_pieces));
5231        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
5232        let (alert_tx, _alert_rx) = broadcast::channel(64);
5233        let (_disk_mgr_tx, _disk_mgr_rx) = mpsc::channel::<crate::disk::DiskJob>(1);
5234
5235        let stream_read_semaphore = Arc::new(tokio::sync::Semaphore::new(8));
5236        let alert_mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5237
5238        // DiskManagerHandle::new spawns an actor — requires runtime.
5239        let (disk_manager, _disk_join) =
5240            crate::disk::DiskManagerHandle::new(crate::disk::DiskConfig::default());
5241
5242        let ban_manager = Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5243            crate::ban::BanConfig::default(),
5244        )));
5245        let ip_filter = Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
5246
5247        let upload_bucket = crate::rate_limiter::TokenBucket::new(0);
5248        let download_bucket = Arc::new(parking_lot::Mutex::new(
5249            crate::rate_limiter::TokenBucket::new(0),
5250        ));
5251        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(0, 0, 0, 0, 0, 0);
5252
5253        let dht_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5254        let dht_v6_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5255        let factory = Arc::new(crate::transport::NetworkFactory::tokio());
5256
5257        // Atomic states + availability sized for `num_pieces`. The
5258        // availability snapshot rebuild reads both; everything else
5259        // (chunk tracker, file priorities, peers) can stay empty.
5260        let we_have = Bitfield::new(num_pieces);
5261        let mut wanted = Bitfield::new(num_pieces);
5262        for i in 0..num_pieces {
5263            wanted.set(i);
5264        }
5265        let atomic_states = Arc::new(crate::piece_reservation::AtomicPieceStates::new(
5266            num_pieces, &we_have, &wanted,
5267        ));
5268
5269        let (order_map_tx, _order_map_rx_seed) =
5270            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
5271
5272        Self {
5273            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(0),
5274            config,
5275            info_hash,
5276            our_peer_id,
5277            state: TorrentState::Downloading,
5278            disk: None,
5279            disk_manager,
5280            chunk_tracker: None,
5281            lengths: None,
5282            num_pieces,
5283            file_priorities: Vec::new(),
5284            wanted_pieces: Bitfield::new(num_pieces),
5285            end_game: EndGame::new(),
5286            streaming_pieces: BTreeSet::new(),
5287            time_critical_pieces: BTreeSet::new(),
5288            streaming_cursors: Vec::new(),
5289            piece_ready_tx,
5290            have_watch_tx,
5291            have_watch_rx,
5292            stream_read_semaphore,
5293            peers: HashMap::new(),
5294            unchoke_durations: HashMap::new(),
5295            cached_peer_rates: FxHashMap::default(),
5296            refill_notify: Arc::new(tokio::sync::Notify::new()),
5297            atomic_states: Some(atomic_states),
5298            block_maps: None,
5299            steal_candidates: None,
5300            last_steal_populate: Instant::now(),
5301            piece_write_guards: None,
5302            soft_reap_buf: Vec::new(),
5303            eviction_history: std::collections::VecDeque::new(),
5304            force_immediate_choker_tick: false,
5305            piece_tracker: None,
5306            order_map_tx,
5307            piece_owner: vec![None; num_pieces as usize],
5308            peer_slab: crate::piece_reservation::PeerSlab::new(),
5309            priority_pieces: BTreeSet::new(),
5310            max_in_flight: 512,
5311            reservation_notify: None,
5312            last_tick_dispatch_state: None,
5313            choker: Choker::new(4),
5314            user_seed_mode: false,
5315            user_forced: false,
5316            max_connections: 0,
5317            peer_states: None,
5318            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
5319            connect_permits: HashMap::new(),
5320            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
5321                std::collections::HashMap::new(),
5322            )),
5323            connect_rx: None,
5324            metadata_downloader: None,
5325            meta: None,
5326            cached_files: None,
5327            downloaded: 0,
5328            uploaded: 0,
5329            checking_progress: 0.0,
5330            total_download: 0,
5331            total_upload: 0,
5332            total_failed_bytes: 0,
5333            total_redundant_bytes: 0,
5334            added_time: 0,
5335            completed_time: 0,
5336            last_download: 0,
5337            last_upload: 0,
5338            last_seen_complete: 0,
5339            active_duration: 0,
5340            finished_duration: 0,
5341            seeding_duration: 0,
5342            active_since: None,
5343            state_duration_since: None,
5344            started_at: Instant::now(),
5345            moving_storage: false,
5346            has_incoming: false,
5347            need_save_resume: false,
5348            error: String::new(),
5349            error_file: -1,
5350            cmd_rx,
5351            event_tx,
5352            event_rx,
5353            write_error_rx,
5354            write_error_tx,
5355            verify_result_rx,
5356            verify_result_tx,
5357            pending_verify: HashSet::new(),
5358            piece_generations: vec![0u64; num_pieces as usize],
5359            hash_result_rx,
5360            hash_result_tx,
5361            listener: None,
5362            utp_socket: None,
5363            utp_socket_v6: None,
5364            tracker_manager: TrackerManager::empty(info_hash, our_peer_id, 0, 0, false),
5365            tracker_result_rx: None,
5366            dht_rx,
5367            dht_v6_rx,
5368            dht_enabled: false,
5369            dht_peers_rx: None,
5370            dht_v6_peers_rx: None,
5371            dht_v6_empty_count: 0,
5372            dht_v6_last_retry: None,
5373            alert_tx,
5374            alert_mask,
5375            upload_bucket,
5376            download_bucket,
5377            global_upload_bucket: None,
5378            global_download_bucket: None,
5379            slot_tuner: crate::slot_tuner::SlotTuner::disabled(4),
5380            upload_bytes_interval: 0,
5381            peak_download_rate: 0,
5382            web_seeds: HashMap::new(),
5383            banned_web_seeds: HashSet::new(),
5384            web_seed_in_flight: HashMap::new(),
5385            web_seed_stats: HashMap::new(),
5386            pex_peer_count: 0,
5387            lsd_peer_count: 0,
5388            super_seed: None,
5389            have_broadcast_tx,
5390            suggested_to_peers: HashMap::new(),
5391            predictive_have_sent: HashSet::new(),
5392            ban_manager,
5393            piece_contributors: HashMap::new(),
5394            parole_pieces: HashMap::new(),
5395            ip_filter,
5396            external_ip: None,
5397            share_lru: std::collections::VecDeque::new(),
5398            share_max_pieces: 0,
5399            plugins: Arc::new(Vec::new()),
5400            hash_picker: None,
5401            version: irontide_core::TorrentVersion::V1Only,
5402            meta_v2: None,
5403            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
5404            dht_v2_peers_rx: None,
5405            dht_v6_v2_peers_rx: None,
5406            magnet_selected_files: None,
5407            sam_session: None,
5408            i2p_accept_rx: None,
5409            i2p_peer_counter: 0,
5410            i2p_destinations: HashMap::new(),
5411            ssl_manager: None,
5412            rate_limiter_set,
5413            auto_sequential_active: false,
5414            factory,
5415            hash_pool_ref: None,
5416            connect_attempts: 0,
5417            connect_failures: 0,
5418            choke_rotations: 0,
5419            inflight_started: Vec::new(),
5420            completed_piece_times: std::collections::VecDeque::new(),
5421            piece_steals: 0,
5422            holepunch_relayed: 0,
5423            holepunch_relay_rate: HashMap::new(),
5424            holepunch_cooldowns: HashMap::new(),
5425            holepunch_pending: Vec::new(),
5426            counters: Arc::new(crate::stats::SessionCounters::new()),
5427        }
5428    }
5429}
5430
5431// ============================================================================
5432// Tests
5433// ============================================================================
5434
5435#[cfg(test)]
5436mod tests {
5437    use super::*;
5438    use bytes::Bytes;
5439    use futures::{SinkExt, StreamExt};
5440    use irontide_wire::{ExtHandshake, Handshake, Message, MessageCodec};
5441    use std::time::Duration;
5442    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5443    use tokio::net::TcpListener;
5444    use tokio_util::codec::{FramedRead, FramedWrite};
5445
5446    // M224: initial unchoke slot derivation from Settings.max_uploads_per_torrent.
5447
5448    #[test]
5449    fn initial_unchoke_slots_unlimited_returns_default_four() {
5450        assert_eq!(initial_unchoke_slots(-1), 4);
5451    }
5452
5453    #[test]
5454    fn initial_unchoke_slots_capped_returns_value() {
5455        assert_eq!(initial_unchoke_slots(1), 1);
5456        assert_eq!(initial_unchoke_slots(4), 4);
5457        assert_eq!(initial_unchoke_slots(16), 16);
5458    }
5459
5460    // -- Helpers --
5461
5462    /// Build a valid `TorrentMetaV1` from raw data with given piece length.
5463    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
5464        use serde::Serialize;
5465
5466        #[derive(Serialize)]
5467        struct Info<'a> {
5468            length: u64,
5469            name: &'a str,
5470            #[serde(rename = "piece length")]
5471            piece_length: u64,
5472            #[serde(with = "serde_bytes")]
5473            pieces: &'a [u8],
5474        }
5475
5476        #[derive(Serialize)]
5477        struct Torrent<'a> {
5478            info: Info<'a>,
5479        }
5480
5481        let mut pieces = Vec::new();
5482        let mut offset = 0;
5483        while offset < data.len() {
5484            let end = (offset + piece_length as usize).min(data.len());
5485            let hash = irontide_core::sha1(&data[offset..end]);
5486            pieces.extend_from_slice(hash.as_bytes());
5487            offset = end;
5488        }
5489
5490        let t = Torrent {
5491            info: Info {
5492                length: data.len() as u64,
5493                name: "test",
5494                piece_length,
5495                pieces: &pieces,
5496            },
5497        };
5498
5499        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5500        torrent_from_bytes(&bytes).unwrap()
5501    }
5502
5503    fn test_config() -> TorrentConfig {
5504        TorrentConfig {
5505            listen_port: 0, // random port
5506            max_peers: 200,
5507            target_request_queue: 5,
5508            download_dir: std::path::PathBuf::from("/tmp"),
5509            enable_dht: false,
5510            enable_pex: false,
5511            enable_fast: false,
5512            seed_ratio_limit: None,
5513            seed_time_limit_secs: None,
5514            inactive_seed_time_limit_secs: None,
5515            strict_end_game: true,
5516            upload_rate_limit: 0,
5517            download_rate_limit: 0,
5518            max_uploads_per_torrent: -1,
5519            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
5520            enable_utp: false,
5521            enable_web_seed: true,
5522            enable_holepunch: false,
5523            enable_bep40_eviction: true,
5524            max_web_seeds: 4,
5525            web_seed_retry_base_secs: 10,
5526            web_seed_retry_factor: 6,
5527            web_seed_retry_cap_secs: 3600,
5528            web_seed_max_failures: 10,
5529            super_seeding: false,
5530            upload_only_announce: true,
5531            hashing_threads: 2,
5532            sequential_download: false,
5533            initial_picker_threshold: 4,
5534            whole_pieces_threshold: 20,
5535            snub_timeout_secs: 15,
5536            readahead_pieces: 8,
5537            streaming_timeout_escalation: true,
5538            max_concurrent_stream_reads: 8,
5539            proxy: crate::proxy::ProxyConfig::default(),
5540            anonymous_mode: false,
5541            share_mode: false,
5542            enable_i2p: false,
5543            allow_i2p_mixed: false,
5544            ssl_listen_port: 0,
5545            seed_choking_algorithm: crate::choker::SeedChokingAlgorithm::FastestUpload,
5546            choking_algorithm: crate::choker::ChokingAlgorithm::FixedSlots,
5547            piece_extent_affinity: true,
5548            suggest_mode: false,
5549            max_suggest_pieces: 10,
5550            predictive_piece_announce_ms: 0,
5551            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
5552            auto_sequential: true,
5553            storage_mode: irontide_core::StorageMode::Auto,
5554            preallocate_mode: None,
5555            block_request_timeout_secs: 60,
5556            enable_lsd: false,
5557            force_proxy: false,
5558            steal_threshold_ratio: 10.0,
5559            steal_threshold_endgame: 3.0,
5560            peer_read_timeout_secs: 0,         // disabled in tests
5561            peer_write_timeout_secs: 0,        // disabled in tests
5562            data_contribution_timeout_secs: 0, // disabled in tests
5563            // v0.187.3 eviction tunables — defaults that match production.
5564            pass0_grace_secs: 60,
5565            proactive_evictions_per_minute_limit: 30,
5566            eviction_ban_duration_secs: 600,
5567            eviction_ban_set_cap: 1024,
5568            choke_rotation_max_evictions: 0, // disabled in tests
5569            max_concurrent_connects: 128,
5570            connect_soft_timeout: 3,
5571            dispatch_backlog_cap: 8,
5572            event_backlog_cap: 32,
5573            use_actor_dispatch: true,
5574            web_seed_progress_throttle_ms: 250,
5575            url_security: crate::url_guard::UrlSecurityConfig::default(),
5576            peer_connect_timeout: 2,
5577            peer_dscp: 0x08,
5578            initial_queue_depth: 128,
5579            max_request_queue_depth: 250,
5580            request_queue_time: 3.0,
5581            max_metadata_size: 4 * 1024 * 1024,
5582            max_message_size: 16 * 1024 * 1024,
5583            max_piece_length: 32 * 1024 * 1024,
5584            max_outstanding_requests: 500,
5585            max_in_flight_pieces: 20,
5586            use_block_stealing: true,
5587            steal_stale_piece_secs: 2,
5588            fixed_pipeline_depth: 128,
5589            lock_warn_threshold_ms: 0, // disabled in tests
5590            filesystem_direct_io: false,
5591            category: None,
5592            tags: Vec::new(),
5593        }
5594    }
5595
5596    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5597        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5598        Arc::new(MemoryStorage::new(lengths))
5599    }
5600
5601    fn make_seeded_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5602        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5603        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
5604        // Write data piece by piece
5605        let num_pieces = lengths.num_pieces();
5606        for p in 0..num_pieces {
5607            let piece_size = lengths.piece_size(p) as usize;
5608            let offset = lengths.piece_offset(p) as usize;
5609            let end = offset + piece_size;
5610            storage.write_chunk(p, 0, &data[offset..end]).unwrap();
5611        }
5612        storage
5613    }
5614
5615    fn test_alert_channel() -> (broadcast::Sender<Alert>, Arc<AtomicU32>) {
5616        let (tx, _) = broadcast::channel(64);
5617        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5618        (tx, mask)
5619    }
5620
5621    fn test_ban_manager() -> crate::session::SharedBanManager {
5622        Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5623            crate::ban::BanConfig::default(),
5624        )))
5625    }
5626
5627    fn test_ip_filter() -> crate::session::SharedIpFilter {
5628        Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()))
5629    }
5630
5631    fn test_disk_manager() -> (DiskManagerHandle, tokio::task::JoinHandle<()>) {
5632        DiskManagerHandle::new(crate::disk::DiskConfig::default())
5633    }
5634
5635    async fn test_register_disk(
5636        info_hash: Id20,
5637        storage: Arc<dyn TorrentStorage>,
5638    ) -> (DiskHandle, DiskManagerHandle, tokio::task::JoinHandle<()>) {
5639        let (dm, join) = test_disk_manager();
5640        let dh = dm.register_torrent(info_hash, storage).await;
5641        (dh, dm, join)
5642    }
5643
5644    /// M173 Lane B (B6): build a `DhtReceiver` pre-populated with `None`
5645    /// — what the test fixtures previously passed as `dht: None`.
5646    fn test_dht_rx() -> irontide_dht::DhtReceiver {
5647        // `&'static` storage so each call returns a fresh subscriber
5648        // without leaking the underlying broadcast.
5649        let bx = irontide_dht::DhtBroadcast::new(None);
5650        bx.subscribe()
5651    }
5652
5653    /// Handshake size constant.
5654    const HANDSHAKE_SIZE: usize = 68;
5655
5656    // ---- Test 1: Create from torrent ----
5657
5658    #[tokio::test]
5659    async fn create_from_torrent() {
5660        let data = vec![0xAB; 32768]; // 32 KiB
5661        let meta = make_test_torrent(&data, 16384); // 2 pieces
5662        let storage = make_storage(&data, 16384);
5663        let config = test_config();
5664
5665        let (atx, amask) = test_alert_channel();
5666        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5667        let handle = TorrentHandle::from_torrent(
5668            meta,
5669            irontide_core::TorrentVersion::V1Only,
5670            None,
5671            dh,
5672            dm,
5673            config,
5674            test_dht_rx(),
5675            test_dht_rx(),
5676            None,
5677            None,
5678            crate::slot_tuner::SlotTuner::disabled(4),
5679            atx,
5680            amask,
5681            None,
5682            None,
5683            test_ban_manager(),
5684            test_ip_filter(),
5685            Arc::new(Vec::new()),
5686            None,
5687            None,
5688            Arc::new(crate::transport::NetworkFactory::tokio()),
5689            None, // M96: hash_pool
5690            Arc::new(crate::stats::SessionCounters::new()),
5691        )
5692        .await
5693        .unwrap();
5694
5695        let stats = handle.stats().await.unwrap();
5696        assert_eq!(stats.state, TorrentState::Downloading);
5697        assert_eq!(stats.pieces_total, 2);
5698        assert_eq!(stats.pieces_have, 0);
5699        assert_eq!(stats.peers_connected, 0);
5700
5701        handle.shutdown().await.unwrap();
5702    }
5703
5704    // ---- Test 2: Create from magnet ----
5705
5706    #[tokio::test]
5707    async fn create_from_magnet() {
5708        let magnet = Magnet {
5709            info_hashes: irontide_core::InfoHashes::v1_only(
5710                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
5711            ),
5712            display_name: Some("test".into()),
5713            trackers: vec![],
5714            peers: vec![],
5715            selected_files: None,
5716        };
5717        let config = test_config();
5718
5719        let (atx, amask) = test_alert_channel();
5720        let (dm, _dj) = test_disk_manager();
5721        let handle = TorrentHandle::from_magnet(
5722            magnet,
5723            dm,
5724            config,
5725            test_dht_rx(),
5726            test_dht_rx(),
5727            None,
5728            None,
5729            crate::slot_tuner::SlotTuner::disabled(4),
5730            atx,
5731            amask,
5732            None,
5733            None,
5734            test_ban_manager(),
5735            test_ip_filter(),
5736            Arc::new(Vec::new()),
5737            None,
5738            None,
5739            Arc::new(crate::transport::NetworkFactory::tokio()),
5740            None, // M96: hash_pool
5741            Arc::new(crate::stats::SessionCounters::new()),
5742        )
5743        .await
5744        .unwrap();
5745
5746        let stats = handle.stats().await.unwrap();
5747        assert_eq!(stats.state, TorrentState::FetchingMetadata);
5748        assert_eq!(stats.pieces_total, 0);
5749
5750        handle.shutdown().await.unwrap();
5751    }
5752
5753    // ---- Test 3: Add peers ----
5754
5755    #[tokio::test]
5756    async fn add_peers_increases_available() {
5757        let data = vec![0xAB; 32768];
5758        let meta = make_test_torrent(&data, 16384);
5759        let storage = make_storage(&data, 16384);
5760        let config = test_config();
5761
5762        let (atx, amask) = test_alert_channel();
5763        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5764        let handle = TorrentHandle::from_torrent(
5765            meta,
5766            irontide_core::TorrentVersion::V1Only,
5767            None,
5768            dh,
5769            dm,
5770            config,
5771            test_dht_rx(),
5772            test_dht_rx(),
5773            None,
5774            None,
5775            crate::slot_tuner::SlotTuner::disabled(4),
5776            atx,
5777            amask,
5778            None,
5779            None,
5780            test_ban_manager(),
5781            test_ip_filter(),
5782            Arc::new(Vec::new()),
5783            None,
5784            None,
5785            Arc::new(crate::transport::NetworkFactory::tokio()),
5786            None, // M96: hash_pool
5787            Arc::new(crate::stats::SessionCounters::new()),
5788        )
5789        .await
5790        .unwrap();
5791
5792        // Bind listeners so the connect attempts succeed and peers stay in connected state
5793        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
5794        let addr1 = listener1.local_addr().unwrap();
5795        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
5796        let addr2 = listener2.local_addr().unwrap();
5797
5798        handle
5799            .add_peers(vec![addr1, addr2], PeerSource::Tracker)
5800            .await
5801            .unwrap();
5802
5803        // Small delay for the actor to process
5804        tokio::time::sleep(Duration::from_millis(100)).await;
5805
5806        let stats = handle.stats().await.unwrap();
5807        // Peers may be available or already connecting (try_connect_peers fires immediately)
5808        assert!(
5809            stats.peers_available + stats.peers_connected >= 2,
5810            "expected at least 2 peers known, got available={}, connected={}",
5811            stats.peers_available,
5812            stats.peers_connected
5813        );
5814
5815        handle.shutdown().await.unwrap();
5816    }
5817
5818    // ---- Test 4: Stats reporting ----
5819
5820    #[tokio::test]
5821    async fn stats_reporting() {
5822        let data = vec![0xAB; 65536]; // 64 KiB
5823        let meta = make_test_torrent(&data, 16384); // 4 pieces
5824        let storage = make_storage(&data, 16384);
5825        let config = test_config();
5826
5827        let (atx, amask) = test_alert_channel();
5828        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5829        let handle = TorrentHandle::from_torrent(
5830            meta,
5831            irontide_core::TorrentVersion::V1Only,
5832            None,
5833            dh,
5834            dm,
5835            config,
5836            test_dht_rx(),
5837            test_dht_rx(),
5838            None,
5839            None,
5840            crate::slot_tuner::SlotTuner::disabled(4),
5841            atx,
5842            amask,
5843            None,
5844            None,
5845            test_ban_manager(),
5846            test_ip_filter(),
5847            Arc::new(Vec::new()),
5848            None,
5849            None,
5850            Arc::new(crate::transport::NetworkFactory::tokio()),
5851            None, // M96: hash_pool
5852            Arc::new(crate::stats::SessionCounters::new()),
5853        )
5854        .await
5855        .unwrap();
5856
5857        let stats = handle.stats().await.unwrap();
5858        assert_eq!(stats.state, TorrentState::Downloading);
5859        assert_eq!(stats.downloaded, 0);
5860        assert_eq!(stats.uploaded, 0);
5861        assert_eq!(stats.pieces_have, 0);
5862        assert_eq!(stats.pieces_total, 4);
5863        assert_eq!(stats.peers_connected, 0);
5864        assert_eq!(stats.peers_available, 0);
5865
5866        handle.shutdown().await.unwrap();
5867    }
5868
5869    // ---- Test 5: Private torrent disables DHT/PEX ----
5870
5871    #[tokio::test]
5872    async fn private_torrent_disables_dht_pex() {
5873        // Build a private torrent by embedding private=1 in the info dict
5874        use serde::Serialize;
5875
5876        #[derive(Serialize)]
5877        struct Info<'a> {
5878            length: u64,
5879            name: &'a str,
5880            #[serde(rename = "piece length")]
5881            piece_length: u64,
5882            #[serde(with = "serde_bytes")]
5883            pieces: &'a [u8],
5884            private: i64,
5885        }
5886
5887        #[derive(Serialize)]
5888        struct Torrent<'a> {
5889            info: Info<'a>,
5890        }
5891
5892        let data = vec![0xAB; 16384];
5893        let hash = irontide_core::sha1(&data);
5894        let mut pieces = Vec::new();
5895        pieces.extend_from_slice(hash.as_bytes());
5896
5897        let t = Torrent {
5898            info: Info {
5899                length: data.len() as u64,
5900                name: "private_test",
5901                piece_length: 16384,
5902                pieces: &pieces,
5903                private: 1,
5904            },
5905        };
5906
5907        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5908        let meta = torrent_from_bytes(&bytes).unwrap();
5909        assert_eq!(meta.info.private, Some(1));
5910
5911        let storage = make_storage(&data, 16384);
5912        let mut config = test_config();
5913        config.enable_dht = true;
5914        config.enable_pex = true;
5915
5916        // The from_torrent constructor should disable DHT and PEX
5917        let (atx, amask) = test_alert_channel();
5918        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5919        let handle = TorrentHandle::from_torrent(
5920            meta,
5921            irontide_core::TorrentVersion::V1Only,
5922            None,
5923            dh,
5924            dm,
5925            config,
5926            test_dht_rx(),
5927            test_dht_rx(),
5928            None,
5929            None,
5930            crate::slot_tuner::SlotTuner::disabled(4),
5931            atx,
5932            amask,
5933            None,
5934            None,
5935            test_ban_manager(),
5936            test_ip_filter(),
5937            Arc::new(Vec::new()),
5938            None,
5939            None,
5940            Arc::new(crate::transport::NetworkFactory::tokio()),
5941            None, // M96: hash_pool
5942            Arc::new(crate::stats::SessionCounters::new()),
5943        )
5944        .await
5945        .unwrap();
5946
5947        // We can't directly inspect the actor's config, but we can verify
5948        // the torrent was created successfully. The real test is that PEX peers
5949        // would be ignored and DHT not used. For now verify the handle works.
5950        let stats = handle.stats().await.unwrap();
5951        assert_eq!(stats.state, TorrentState::Downloading);
5952
5953        handle.shutdown().await.unwrap();
5954    }
5955
5956    // ---- Test 6: Shutdown cleanup ----
5957
5958    #[tokio::test]
5959    async fn shutdown_cleanup() {
5960        let data = vec![0xAB; 16384];
5961        let meta = make_test_torrent(&data, 16384);
5962        let storage = make_storage(&data, 16384);
5963        let config = test_config();
5964
5965        let (atx, amask) = test_alert_channel();
5966        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5967        let handle = TorrentHandle::from_torrent(
5968            meta,
5969            irontide_core::TorrentVersion::V1Only,
5970            None,
5971            dh,
5972            dm,
5973            config,
5974            test_dht_rx(),
5975            test_dht_rx(),
5976            None,
5977            None,
5978            crate::slot_tuner::SlotTuner::disabled(4),
5979            atx,
5980            amask,
5981            None,
5982            None,
5983            test_ban_manager(),
5984            test_ip_filter(),
5985            Arc::new(Vec::new()),
5986            None,
5987            None,
5988            Arc::new(crate::transport::NetworkFactory::tokio()),
5989            None, // M96: hash_pool
5990            Arc::new(crate::stats::SessionCounters::new()),
5991        )
5992        .await
5993        .unwrap();
5994
5995        handle.shutdown().await.unwrap();
5996
5997        // After shutdown, stats should fail (channel closed)
5998        tokio::time::sleep(Duration::from_millis(50)).await;
5999        let result = handle.stats().await;
6000        assert!(result.is_err());
6001    }
6002
6003    // ---- Test 7: Duplicate add_peers ignored ----
6004
6005    #[tokio::test]
6006    async fn duplicate_peers_ignored() {
6007        let data = vec![0xAB; 16384];
6008        let meta = make_test_torrent(&data, 16384);
6009        let storage = make_storage(&data, 16384);
6010        let config = test_config();
6011
6012        let (atx, amask) = test_alert_channel();
6013        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6014        let handle = TorrentHandle::from_torrent(
6015            meta,
6016            irontide_core::TorrentVersion::V1Only,
6017            None,
6018            dh,
6019            dm,
6020            config,
6021            test_dht_rx(),
6022            test_dht_rx(),
6023            None,
6024            None,
6025            crate::slot_tuner::SlotTuner::disabled(4),
6026            atx,
6027            amask,
6028            None,
6029            None,
6030            test_ban_manager(),
6031            test_ip_filter(),
6032            Arc::new(Vec::new()),
6033            None,
6034            None,
6035            Arc::new(crate::transport::NetworkFactory::tokio()),
6036            None, // M96: hash_pool
6037            Arc::new(crate::stats::SessionCounters::new()),
6038        )
6039        .await
6040        .unwrap();
6041
6042        // Bind a listener so the connection succeeds and the peer stays connected
6043        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6044        let addr = listener.local_addr().unwrap();
6045        handle
6046            .add_peers(vec![addr, addr, addr], PeerSource::Tracker)
6047            .await
6048            .unwrap();
6049
6050        tokio::time::sleep(Duration::from_millis(100)).await;
6051        let stats = handle.stats().await.unwrap();
6052        // Only one unique peer should be known (available or connecting)
6053        assert!(
6054            stats.peers_available + stats.peers_connected <= 1,
6055            "expected at most 1 unique peer, got available={}, connected={}",
6056            stats.peers_available,
6057            stats.peers_connected
6058        );
6059
6060        handle.shutdown().await.unwrap();
6061    }
6062
6063    // ---- Test 8: Multiple handles (Clone) share same actor ----
6064
6065    #[tokio::test]
6066    async fn cloned_handle_shares_actor() {
6067        let data = vec![0xAB; 16384];
6068        let meta = make_test_torrent(&data, 16384);
6069        let storage = make_storage(&data, 16384);
6070        let config = test_config();
6071
6072        let (atx, amask) = test_alert_channel();
6073        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6074        let handle = TorrentHandle::from_torrent(
6075            meta,
6076            irontide_core::TorrentVersion::V1Only,
6077            None,
6078            dh,
6079            dm,
6080            config,
6081            test_dht_rx(),
6082            test_dht_rx(),
6083            None,
6084            None,
6085            crate::slot_tuner::SlotTuner::disabled(4),
6086            atx,
6087            amask,
6088            None,
6089            None,
6090            test_ban_manager(),
6091            test_ip_filter(),
6092            Arc::new(Vec::new()),
6093            None,
6094            None,
6095            Arc::new(crate::transport::NetworkFactory::tokio()),
6096            None, // M96: hash_pool
6097            Arc::new(crate::stats::SessionCounters::new()),
6098        )
6099        .await
6100        .unwrap();
6101        let handle2 = handle.clone();
6102
6103        // Bind a listener so the connection succeeds and the peer stays connected
6104        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6105        let peer_addr = listener.local_addr().unwrap();
6106
6107        // Add peers through one handle
6108        handle
6109            .add_peers(vec![peer_addr], PeerSource::Tracker)
6110            .await
6111            .unwrap();
6112
6113        tokio::time::sleep(Duration::from_millis(100)).await;
6114
6115        // Read stats through the other — peer may be available or connecting
6116        let stats = handle2.stats().await.unwrap();
6117        assert!(
6118            stats.peers_available + stats.peers_connected >= 1,
6119            "expected at least 1 peer known, got available={}, connected={}",
6120            stats.peers_available,
6121            stats.peers_connected
6122        );
6123
6124        handle.shutdown().await.unwrap();
6125    }
6126
6127    // ---- Test 9: Peer connection and disconnect via listener ----
6128
6129    #[tokio::test]
6130    async fn peer_connect_and_disconnect_via_listener() {
6131        let data = vec![0xAB; 16384];
6132        let meta = make_test_torrent(&data, 16384);
6133        let info_hash = meta.info_hash;
6134        let storage = make_storage(&data, 16384);
6135
6136        // Bind a listener on a specific port so we can connect to it
6137        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6138        let listen_addr = listener.local_addr().unwrap();
6139
6140        let config = TorrentConfig {
6141            listen_port: listen_addr.port(),
6142            ..test_config()
6143        };
6144
6145        // Drop the pre-bound listener before from_torrent binds
6146        drop(listener);
6147
6148        let (atx, amask) = test_alert_channel();
6149        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6150        let handle = TorrentHandle::from_torrent(
6151            meta,
6152            irontide_core::TorrentVersion::V1Only,
6153            None,
6154            dh,
6155            dm,
6156            config,
6157            test_dht_rx(),
6158            test_dht_rx(),
6159            None,
6160            None,
6161            crate::slot_tuner::SlotTuner::disabled(4),
6162            atx,
6163            amask,
6164            None,
6165            None,
6166            test_ban_manager(),
6167            test_ip_filter(),
6168            Arc::new(Vec::new()),
6169            None,
6170            None,
6171            Arc::new(crate::transport::NetworkFactory::tokio()),
6172            None, // M96: hash_pool
6173            Arc::new(crate::stats::SessionCounters::new()),
6174        )
6175        .await
6176        .unwrap();
6177
6178        // Give the actor time to start
6179        tokio::time::sleep(Duration::from_millis(50)).await;
6180
6181        // Connect a mock peer
6182        let mut stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6183
6184        // Perform handshake
6185        let remote_id = Id20::from_hex("1111111111111111111111111111111111111111").unwrap();
6186        let remote_hs = Handshake::new(info_hash, remote_id);
6187        stream.write_all(&remote_hs.to_bytes()).await.unwrap();
6188        stream.flush().await.unwrap();
6189
6190        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6191        stream.read_exact(&mut hs_buf).await.unwrap();
6192        let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
6193        assert_eq!(their_hs.info_hash, info_hash);
6194
6195        // Give time for peer to be registered
6196        tokio::time::sleep(Duration::from_millis(100)).await;
6197
6198        let stats = handle.stats().await.unwrap();
6199        assert_eq!(stats.peers_connected, 1);
6200
6201        // Drop the connection
6202        drop(stream);
6203
6204        // Wait for disconnect event
6205        tokio::time::sleep(Duration::from_millis(200)).await;
6206
6207        let stats = handle.stats().await.unwrap();
6208        assert_eq!(stats.peers_connected, 0);
6209
6210        handle.shutdown().await.unwrap();
6211    }
6212
6213    // ---- Test 10: Piece download and verification via injected events ----
6214    //
6215    // We test the full flow: connect a mock peer that sends bitfield, unchoke,
6216    // then responds to requests with correct piece data.
6217
6218    #[tokio::test]
6219    async fn piece_download_and_verify() {
6220        // Create a 1-piece torrent with 16384 bytes (exactly one chunk)
6221        let data = vec![0xCDu8; 16384];
6222        let meta = make_test_torrent(&data, 16384);
6223        let info_hash = meta.info_hash;
6224        let storage = make_storage(&data, 16384);
6225
6226        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6227        let listen_addr = listener.local_addr().unwrap();
6228        drop(listener);
6229
6230        let config = TorrentConfig {
6231            listen_port: listen_addr.port(),
6232            ..test_config()
6233        };
6234
6235        let (atx, amask) = test_alert_channel();
6236        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6237        let handle = TorrentHandle::from_torrent(
6238            meta,
6239            irontide_core::TorrentVersion::V1Only,
6240            None,
6241            dh,
6242            dm,
6243            config,
6244            test_dht_rx(),
6245            test_dht_rx(),
6246            None,
6247            None,
6248            crate::slot_tuner::SlotTuner::disabled(4),
6249            atx,
6250            amask,
6251            None,
6252            None,
6253            test_ban_manager(),
6254            test_ip_filter(),
6255            Arc::new(Vec::new()),
6256            None,
6257            None,
6258            Arc::new(crate::transport::NetworkFactory::tokio()),
6259            None, // M96: hash_pool
6260            Arc::new(crate::stats::SessionCounters::new()),
6261        )
6262        .await
6263        .unwrap();
6264
6265        tokio::time::sleep(Duration::from_millis(50)).await;
6266
6267        // Connect mock peer
6268        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6269        let remote_id = Id20::from_hex("2222222222222222222222222222222222222222").unwrap();
6270
6271        // Run mock seeder in a task
6272        let mock_data = data.clone();
6273        let mock_task = tokio::spawn(async move {
6274            let (reader, writer) = tokio::io::split(stream);
6275            let mut reader = reader;
6276            let mut writer = writer;
6277
6278            // Handshake
6279            let hs = Handshake::new(info_hash, remote_id);
6280            writer.write_all(&hs.to_bytes()).await.unwrap();
6281            writer.flush().await.unwrap();
6282
6283            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6284            reader.read_exact(&mut hs_buf).await.unwrap();
6285
6286            // Switch to framed
6287            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6288            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6289
6290            // Read ext handshake from the torrent actor's peer
6291            let _msg = framed_read.next().await;
6292
6293            // Send ext handshake back
6294            let ext_hs = ExtHandshake::new();
6295            let payload = ext_hs.to_bytes().unwrap();
6296            framed_write
6297                .send(Message::Extended { ext_id: 0, payload })
6298                .await
6299                .unwrap();
6300
6301            // Send bitfield (all pieces = piece 0 set)
6302            let mut bf = Bitfield::new(1);
6303            bf.set(0);
6304            framed_write
6305                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6306                .await
6307                .unwrap();
6308
6309            // Send Unchoke
6310            framed_write.send(Message::Unchoke).await.unwrap();
6311
6312            // Wait for requests and respond with piece data
6313            while let Some(Ok(msg)) = framed_read.next().await {
6314                if let Message::Request {
6315                    index,
6316                    begin,
6317                    length,
6318                } = msg
6319                {
6320                    let start = begin as usize;
6321                    let end = start + length as usize;
6322                    let piece_data = &mock_data[start..end];
6323                    framed_write
6324                        .send(Message::Piece {
6325                            index,
6326                            begin,
6327                            data_0: Bytes::copy_from_slice(piece_data),
6328                            data_1: Bytes::new(),
6329                        })
6330                        .await
6331                        .unwrap();
6332                }
6333            }
6334        });
6335
6336        // Wait for the download to complete
6337        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6338        loop {
6339            tokio::time::sleep(Duration::from_millis(100)).await;
6340            let stats = handle.stats().await.unwrap();
6341            if stats.state == TorrentState::Seeding {
6342                assert_eq!(stats.pieces_have, 1);
6343                assert_eq!(stats.pieces_total, 1);
6344                break;
6345            }
6346            if tokio::time::Instant::now() > deadline {
6347                let stats = handle.stats().await.unwrap();
6348                panic!(
6349                    "download did not complete within 5s, state={:?}, have={}/{}",
6350                    stats.state, stats.pieces_have, stats.pieces_total
6351                );
6352            }
6353        }
6354
6355        handle.shutdown().await.unwrap();
6356        mock_task.abort();
6357    }
6358
6359    // ---- Test 11: Failed piece verification re-requests ----
6360
6361    #[tokio::test]
6362    async fn failed_piece_verification() {
6363        // Create a 1-piece torrent
6364        let data = vec![0xEEu8; 16384];
6365        let meta = make_test_torrent(&data, 16384);
6366        let info_hash = meta.info_hash;
6367        let storage = make_storage(&data, 16384);
6368
6369        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6370        let listen_addr = listener.local_addr().unwrap();
6371        drop(listener);
6372
6373        let config = TorrentConfig {
6374            listen_port: listen_addr.port(),
6375            ..test_config()
6376        };
6377
6378        let (atx, amask) = test_alert_channel();
6379        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6380        let handle = TorrentHandle::from_torrent(
6381            meta,
6382            irontide_core::TorrentVersion::V1Only,
6383            None,
6384            dh,
6385            dm,
6386            config,
6387            test_dht_rx(),
6388            test_dht_rx(),
6389            None,
6390            None,
6391            crate::slot_tuner::SlotTuner::disabled(4),
6392            atx,
6393            amask,
6394            None,
6395            None,
6396            test_ban_manager(),
6397            test_ip_filter(),
6398            Arc::new(Vec::new()),
6399            None,
6400            None,
6401            Arc::new(crate::transport::NetworkFactory::tokio()),
6402            None, // M96: hash_pool
6403            Arc::new(crate::stats::SessionCounters::new()),
6404        )
6405        .await
6406        .unwrap();
6407
6408        tokio::time::sleep(Duration::from_millis(50)).await;
6409
6410        // Connect mock peer that first sends bad data, then correct data
6411        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6412        let remote_id = Id20::from_hex("3333333333333333333333333333333333333333").unwrap();
6413
6414        let correct_data = data.clone();
6415        let mock_task = tokio::spawn(async move {
6416            let (reader, writer) = tokio::io::split(stream);
6417
6418            // Handshake
6419            let mut writer = writer;
6420            let mut reader = reader;
6421            let hs = Handshake::new(info_hash, remote_id);
6422            writer.write_all(&hs.to_bytes()).await.unwrap();
6423            writer.flush().await.unwrap();
6424
6425            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6426            reader.read_exact(&mut hs_buf).await.unwrap();
6427
6428            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6429            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6430
6431            // Read ext handshake
6432            let _msg = framed_read.next().await;
6433
6434            // Send ext handshake
6435            let ext_hs = ExtHandshake::new();
6436            let payload = ext_hs.to_bytes().unwrap();
6437            framed_write
6438                .send(Message::Extended { ext_id: 0, payload })
6439                .await
6440                .unwrap();
6441
6442            // Bitfield: have piece 0
6443            let mut bf = Bitfield::new(1);
6444            bf.set(0);
6445            framed_write
6446                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6447                .await
6448                .unwrap();
6449
6450            // Unchoke
6451            framed_write.send(Message::Unchoke).await.unwrap();
6452
6453            let mut request_count = 0u32;
6454            while let Some(Ok(msg)) = framed_read.next().await {
6455                if let Message::Request {
6456                    index,
6457                    begin,
6458                    length,
6459                } = msg
6460                {
6461                    request_count += 1;
6462                    let piece_data = if request_count <= 1 {
6463                        // First request: send bad data
6464                        vec![0xFF; length as usize]
6465                    } else {
6466                        // Subsequent: send correct data
6467                        let start = begin as usize;
6468                        let end = start + length as usize;
6469                        correct_data[start..end].to_vec()
6470                    };
6471                    framed_write
6472                        .send(Message::Piece {
6473                            index,
6474                            begin,
6475                            data_0: Bytes::from(piece_data),
6476                            data_1: Bytes::new(),
6477                        })
6478                        .await
6479                        .unwrap();
6480                }
6481            }
6482        });
6483
6484        // Wait for completion (should eventually succeed after retry)
6485        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6486        loop {
6487            tokio::time::sleep(Duration::from_millis(100)).await;
6488            let stats = handle.stats().await.unwrap();
6489            if stats.state == TorrentState::Seeding {
6490                assert_eq!(stats.pieces_have, 1);
6491                break;
6492            }
6493            if tokio::time::Instant::now() > deadline {
6494                let stats = handle.stats().await.unwrap();
6495                panic!(
6496                    "download did not complete after retry within 5s, state={:?}, have={}",
6497                    stats.state, stats.pieces_have,
6498                );
6499            }
6500        }
6501
6502        handle.shutdown().await.unwrap();
6503        mock_task.abort();
6504    }
6505
6506    // ---- Test 12: Complete state transitions after all pieces ----
6507
6508    #[tokio::test]
6509    async fn complete_transitions_state() {
6510        // 2-piece torrent, each 16384 bytes (one chunk each)
6511        let data = vec![0xBBu8; 32768];
6512        let meta = make_test_torrent(&data, 16384);
6513        let info_hash = meta.info_hash;
6514        let storage = make_storage(&data, 16384);
6515
6516        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6517        let listen_addr = listener.local_addr().unwrap();
6518        drop(listener);
6519
6520        let config = TorrentConfig {
6521            listen_port: listen_addr.port(),
6522            ..test_config()
6523        };
6524
6525        let (atx, amask) = test_alert_channel();
6526        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6527        let handle = TorrentHandle::from_torrent(
6528            meta,
6529            irontide_core::TorrentVersion::V1Only,
6530            None,
6531            dh,
6532            dm,
6533            config,
6534            test_dht_rx(),
6535            test_dht_rx(),
6536            None,
6537            None,
6538            crate::slot_tuner::SlotTuner::disabled(4),
6539            atx,
6540            amask,
6541            None,
6542            None,
6543            test_ban_manager(),
6544            test_ip_filter(),
6545            Arc::new(Vec::new()),
6546            None,
6547            None,
6548            Arc::new(crate::transport::NetworkFactory::tokio()),
6549            None, // M96: hash_pool
6550            Arc::new(crate::stats::SessionCounters::new()),
6551        )
6552        .await
6553        .unwrap();
6554
6555        tokio::time::sleep(Duration::from_millis(50)).await;
6556
6557        // Mock seeder with all 2 pieces
6558        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6559        let remote_id = Id20::from_hex("4444444444444444444444444444444444444444").unwrap();
6560
6561        let mock_data = data.clone();
6562        let mock_task = tokio::spawn(async move {
6563            let (reader, writer) = tokio::io::split(stream);
6564            let mut writer = writer;
6565            let mut reader = reader;
6566
6567            let hs = Handshake::new(info_hash, remote_id);
6568            writer.write_all(&hs.to_bytes()).await.unwrap();
6569            writer.flush().await.unwrap();
6570
6571            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6572            reader.read_exact(&mut hs_buf).await.unwrap();
6573
6574            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6575            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6576
6577            // Read ext handshake
6578            let _msg = framed_read.next().await;
6579
6580            // Send ext handshake
6581            let ext_hs = ExtHandshake::new();
6582            let payload = ext_hs.to_bytes().unwrap();
6583            framed_write
6584                .send(Message::Extended { ext_id: 0, payload })
6585                .await
6586                .unwrap();
6587
6588            // Bitfield: have both pieces
6589            let mut bf = Bitfield::new(2);
6590            bf.set(0);
6591            bf.set(1);
6592            framed_write
6593                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6594                .await
6595                .unwrap();
6596
6597            framed_write.send(Message::Unchoke).await.unwrap();
6598
6599            while let Some(Ok(msg)) = framed_read.next().await {
6600                if let Message::Request {
6601                    index,
6602                    begin,
6603                    length,
6604                } = msg
6605                {
6606                    let abs_start = (index as usize * 16384) + begin as usize;
6607                    let abs_end = abs_start + length as usize;
6608                    let piece_data = &mock_data[abs_start..abs_end];
6609                    framed_write
6610                        .send(Message::Piece {
6611                            index,
6612                            begin,
6613                            data_0: Bytes::copy_from_slice(piece_data),
6614                            data_1: Bytes::new(),
6615                        })
6616                        .await
6617                        .unwrap();
6618                }
6619            }
6620        });
6621
6622        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6623        loop {
6624            tokio::time::sleep(Duration::from_millis(100)).await;
6625            let stats = handle.stats().await.unwrap();
6626            if stats.state == TorrentState::Seeding {
6627                assert_eq!(stats.pieces_have, 2);
6628                assert_eq!(stats.pieces_total, 2);
6629                break;
6630            }
6631            if tokio::time::Instant::now() > deadline {
6632                let stats = handle.stats().await.unwrap();
6633                panic!(
6634                    "expected Complete, got {:?}, have={}/{}",
6635                    stats.state, stats.pieces_have, stats.pieces_total
6636                );
6637            }
6638        }
6639
6640        handle.shutdown().await.unwrap();
6641        mock_task.abort();
6642    }
6643
6644    // ---- Test 13: Multiple pieces with multi-chunk pieces ----
6645
6646    #[tokio::test]
6647    async fn multi_chunk_piece_download() {
6648        // 1 piece of 32768 bytes = 2 chunks of 16384 each
6649        let data = vec![0xAAu8; 32768];
6650        let meta = make_test_torrent(&data, 32768);
6651        let info_hash = meta.info_hash;
6652        let storage = make_storage(&data, 32768);
6653
6654        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6655        let listen_addr = listener.local_addr().unwrap();
6656        drop(listener);
6657
6658        let config = TorrentConfig {
6659            listen_port: listen_addr.port(),
6660            ..test_config()
6661        };
6662
6663        let (atx, amask) = test_alert_channel();
6664        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6665        let handle = TorrentHandle::from_torrent(
6666            meta,
6667            irontide_core::TorrentVersion::V1Only,
6668            None,
6669            dh,
6670            dm,
6671            config,
6672            test_dht_rx(),
6673            test_dht_rx(),
6674            None,
6675            None,
6676            crate::slot_tuner::SlotTuner::disabled(4),
6677            atx,
6678            amask,
6679            None,
6680            None,
6681            test_ban_manager(),
6682            test_ip_filter(),
6683            Arc::new(Vec::new()),
6684            None,
6685            None,
6686            Arc::new(crate::transport::NetworkFactory::tokio()),
6687            None, // M96: hash_pool
6688            Arc::new(crate::stats::SessionCounters::new()),
6689        )
6690        .await
6691        .unwrap();
6692
6693        tokio::time::sleep(Duration::from_millis(50)).await;
6694
6695        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6696        let remote_id = Id20::from_hex("5555555555555555555555555555555555555555").unwrap();
6697
6698        let mock_data = data.clone();
6699        let mock_task = tokio::spawn(async move {
6700            let (reader, writer) = tokio::io::split(stream);
6701            let mut writer = writer;
6702            let mut reader = reader;
6703
6704            let hs = Handshake::new(info_hash, remote_id);
6705            writer.write_all(&hs.to_bytes()).await.unwrap();
6706            writer.flush().await.unwrap();
6707
6708            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6709            reader.read_exact(&mut hs_buf).await.unwrap();
6710
6711            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6712            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6713
6714            let _msg = framed_read.next().await;
6715
6716            let ext_hs = ExtHandshake::new();
6717            let payload = ext_hs.to_bytes().unwrap();
6718            framed_write
6719                .send(Message::Extended { ext_id: 0, payload })
6720                .await
6721                .unwrap();
6722
6723            let mut bf = Bitfield::new(1);
6724            bf.set(0);
6725            framed_write
6726                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6727                .await
6728                .unwrap();
6729
6730            framed_write.send(Message::Unchoke).await.unwrap();
6731
6732            while let Some(Ok(msg)) = framed_read.next().await {
6733                if let Message::Request {
6734                    index: _,
6735                    begin,
6736                    length,
6737                } = msg
6738                {
6739                    let start = begin as usize;
6740                    let end = start + length as usize;
6741                    framed_write
6742                        .send(Message::Piece {
6743                            index: 0,
6744                            begin,
6745                            data_0: Bytes::copy_from_slice(&mock_data[start..end]),
6746                            data_1: Bytes::new(),
6747                        })
6748                        .await
6749                        .unwrap();
6750                }
6751            }
6752        });
6753
6754        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6755        loop {
6756            tokio::time::sleep(Duration::from_millis(100)).await;
6757            let stats = handle.stats().await.unwrap();
6758            if stats.state == TorrentState::Seeding {
6759                assert_eq!(stats.pieces_have, 1);
6760                break;
6761            }
6762            assert!(
6763                tokio::time::Instant::now() <= deadline,
6764                "multi-chunk download did not complete within 5s"
6765            );
6766        }
6767
6768        handle.shutdown().await.unwrap();
6769        mock_task.abort();
6770    }
6771
6772    // ---- Test 14: Seeder/Leecher integration with two actors ----
6773
6774    #[tokio::test]
6775    async fn seeder_leecher_integration() {
6776        // Seeder has all data, leecher has none. Connect them via TCP.
6777        let data = vec![0xDDu8; 32768]; // 32 KiB, 2 pieces of 16384
6778        let piece_length = 16384u64;
6779        let meta = make_test_torrent(&data, piece_length);
6780        let info_hash = meta.info_hash;
6781
6782        // Seeder: storage pre-filled
6783        let seeder_storage = make_seeded_storage(&data, piece_length);
6784
6785        // For the seeder, we need a from_torrent variant that starts in Complete state
6786        // but still serves pieces. Since our actor starts in Downloading, the seeder
6787        // will just be a mock that accepts and serves.
6788
6789        // Use a mock seeder approach instead (manual protocol handling):
6790        let seeder_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6791        let seeder_addr = seeder_listener.local_addr().unwrap();
6792
6793        let seeder_task = tokio::spawn(async move {
6794            let (stream, _addr) = seeder_listener.accept().await.unwrap();
6795            let (reader, writer) = tokio::io::split(stream);
6796            let mut writer = writer;
6797            let mut reader = reader;
6798
6799            // Handshake
6800            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6801            reader.read_exact(&mut hs_buf).await.unwrap();
6802            let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
6803            assert_eq!(their_hs.info_hash, info_hash);
6804
6805            let hs = Handshake::new(info_hash, PeerId::generate().0);
6806            writer.write_all(&hs.to_bytes()).await.unwrap();
6807            writer.flush().await.unwrap();
6808
6809            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6810            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6811
6812            // Read ext handshake
6813            let _msg = framed_read.next().await;
6814
6815            // Send ext handshake
6816            let ext_hs = ExtHandshake::new();
6817            let payload = ext_hs.to_bytes().unwrap();
6818            framed_write
6819                .send(Message::Extended { ext_id: 0, payload })
6820                .await
6821                .unwrap();
6822
6823            // Send bitfield (all pieces)
6824            let mut bf = Bitfield::new(2);
6825            bf.set(0);
6826            bf.set(1);
6827            framed_write
6828                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6829                .await
6830                .unwrap();
6831
6832            // Unchoke
6833            framed_write.send(Message::Unchoke).await.unwrap();
6834
6835            // Serve requests
6836            while let Some(Ok(msg)) = framed_read.next().await {
6837                if let Message::Request {
6838                    index,
6839                    begin,
6840                    length,
6841                } = msg
6842                {
6843                    let piece_data = seeder_storage.read_chunk(index, begin, length).unwrap();
6844                    framed_write
6845                        .send(Message::Piece {
6846                            index,
6847                            begin,
6848                            data_0: Bytes::from(piece_data),
6849                            data_1: Bytes::new(),
6850                        })
6851                        .await
6852                        .unwrap();
6853                }
6854            }
6855        });
6856
6857        // Leecher: empty storage
6858        let leecher_storage = make_storage(&data, piece_length);
6859        let leecher_meta = make_test_torrent(&data, piece_length);
6860
6861        let leecher_config = test_config();
6862        let (latx, lamask) = test_alert_channel();
6863        let (ldh, ldm, _ldj) = test_register_disk(leecher_meta.info_hash, leecher_storage).await;
6864        let leecher = TorrentHandle::from_torrent(
6865            leecher_meta,
6866            irontide_core::TorrentVersion::V1Only,
6867            None,
6868            ldh,
6869            ldm,
6870            leecher_config,
6871            test_dht_rx(),
6872            test_dht_rx(),
6873            None,
6874            None,
6875            crate::slot_tuner::SlotTuner::disabled(4),
6876            latx,
6877            lamask,
6878            None,
6879            None,
6880            test_ban_manager(),
6881            test_ip_filter(),
6882            Arc::new(Vec::new()),
6883            None,
6884            None,
6885            Arc::new(crate::transport::NetworkFactory::tokio()),
6886            None, // M96: hash_pool
6887            Arc::new(crate::stats::SessionCounters::new()),
6888        )
6889        .await
6890        .unwrap();
6891
6892        // Add seeder as a peer
6893        leecher
6894            .add_peers(vec![seeder_addr], PeerSource::Tracker)
6895            .await
6896            .unwrap();
6897
6898        // Give the connect interval time to fire (it ticks every 5s).
6899        // The actor's try_connect_peers runs on the timer, and also immediately
6900        // when peers are added via AddPeers command. Wait up to 10 seconds.
6901        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
6902        loop {
6903            tokio::time::sleep(Duration::from_millis(200)).await;
6904            let stats = leecher.stats().await.unwrap();
6905            if stats.state == TorrentState::Seeding {
6906                assert_eq!(stats.pieces_have, 2);
6907                assert_eq!(stats.pieces_total, 2);
6908                break;
6909            }
6910            if tokio::time::Instant::now() > deadline {
6911                let stats = leecher.stats().await.unwrap();
6912                panic!(
6913                    "seeder/leecher: leecher did not complete, state={:?}, have={}/{}, connected={}, available={}",
6914                    stats.state,
6915                    stats.pieces_have,
6916                    stats.pieces_total,
6917                    stats.peers_connected,
6918                    stats.peers_available,
6919                );
6920            }
6921        }
6922
6923        leecher.shutdown().await.unwrap();
6924        seeder_task.abort();
6925    }
6926
6927    // ---- Test 15: Magnet stats ----
6928
6929    #[tokio::test]
6930    async fn magnet_initial_stats() {
6931        let magnet = Magnet {
6932            info_hashes: irontide_core::InfoHashes::v1_only(
6933                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
6934            ),
6935            display_name: Some("magnet test".into()),
6936            trackers: vec![],
6937            peers: vec![],
6938            selected_files: None,
6939        };
6940
6941        let (atx, amask) = test_alert_channel();
6942        let (dm, _dj) = test_disk_manager();
6943        let handle = TorrentHandle::from_magnet(
6944            magnet,
6945            dm,
6946            test_config(),
6947            test_dht_rx(),
6948            test_dht_rx(),
6949            None,
6950            None,
6951            crate::slot_tuner::SlotTuner::disabled(4),
6952            atx,
6953            amask,
6954            None,
6955            None,
6956            test_ban_manager(),
6957            test_ip_filter(),
6958            Arc::new(Vec::new()),
6959            None,
6960            None,
6961            Arc::new(crate::transport::NetworkFactory::tokio()),
6962            None, // M96: hash_pool
6963            Arc::new(crate::stats::SessionCounters::new()),
6964        )
6965        .await
6966        .unwrap();
6967
6968        let stats = handle.stats().await.unwrap();
6969        assert_eq!(stats.state, TorrentState::FetchingMetadata);
6970        assert_eq!(stats.pieces_total, 0);
6971        assert_eq!(stats.pieces_have, 0);
6972        assert_eq!(stats.downloaded, 0);
6973        assert_eq!(stats.uploaded, 0);
6974        assert_eq!(stats.peers_connected, 0);
6975        assert_eq!(stats.peers_available, 0);
6976
6977        handle.shutdown().await.unwrap();
6978    }
6979
6980    // ---- Test 16: Tracker manager is populated from torrent metadata ----
6981
6982    #[tokio::test]
6983    async fn tracker_populated_from_metadata() {
6984        use serde::Serialize;
6985
6986        #[derive(Serialize)]
6987        struct Info<'a> {
6988            length: u64,
6989            name: &'a str,
6990            #[serde(rename = "piece length")]
6991            piece_length: u64,
6992            #[serde(with = "serde_bytes")]
6993            pieces: &'a [u8],
6994        }
6995
6996        #[derive(Serialize)]
6997        struct Torrent<'a> {
6998            announce: &'a str,
6999            info: Info<'a>,
7000        }
7001
7002        let data = vec![0xAB; 16384];
7003        let hash = irontide_core::sha1(&data);
7004        let mut pieces = Vec::new();
7005        pieces.extend_from_slice(hash.as_bytes());
7006
7007        let t = Torrent {
7008            announce: "http://tracker.example.com:8080/announce",
7009            info: Info {
7010                length: data.len() as u64,
7011                name: "test",
7012                piece_length: 16384,
7013                pieces: &pieces,
7014            },
7015        };
7016
7017        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7018        let meta = torrent_from_bytes(&bytes).unwrap();
7019        assert!(meta.announce.is_some());
7020
7021        let storage = make_storage(&data, 16384);
7022        let config = test_config();
7023
7024        // The torrent should start and announce to tracker (which will fail since
7025        // the tracker doesn't exist, but that's fine — failures are non-fatal).
7026        let (atx, amask) = test_alert_channel();
7027        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7028        let handle = TorrentHandle::from_torrent(
7029            meta,
7030            irontide_core::TorrentVersion::V1Only,
7031            None,
7032            dh,
7033            dm,
7034            config,
7035            test_dht_rx(),
7036            test_dht_rx(),
7037            None,
7038            None,
7039            crate::slot_tuner::SlotTuner::disabled(4),
7040            atx,
7041            amask,
7042            None,
7043            None,
7044            test_ban_manager(),
7045            test_ip_filter(),
7046            Arc::new(Vec::new()),
7047            None,
7048            None,
7049            Arc::new(crate::transport::NetworkFactory::tokio()),
7050            None, // M96: hash_pool
7051            Arc::new(crate::stats::SessionCounters::new()),
7052        )
7053        .await
7054        .unwrap();
7055
7056        let stats = handle.stats().await.unwrap();
7057        assert_eq!(stats.state, TorrentState::Downloading);
7058
7059        handle.shutdown().await.unwrap();
7060    }
7061
7062    // ---- Test 17: Private torrent with DHT=None works ----
7063
7064    #[tokio::test]
7065    async fn private_torrent_no_dht_field() {
7066        use serde::Serialize;
7067
7068        #[derive(Serialize)]
7069        struct Info<'a> {
7070            length: u64,
7071            name: &'a str,
7072            #[serde(rename = "piece length")]
7073            piece_length: u64,
7074            #[serde(with = "serde_bytes")]
7075            pieces: &'a [u8],
7076            private: i64,
7077        }
7078
7079        #[derive(Serialize)]
7080        struct Torrent<'a> {
7081            announce: &'a str,
7082            info: Info<'a>,
7083        }
7084
7085        let data = vec![0xAB; 16384];
7086        let hash = irontide_core::sha1(&data);
7087        let mut pieces = Vec::new();
7088        pieces.extend_from_slice(hash.as_bytes());
7089
7090        let t = Torrent {
7091            announce: "http://private-tracker.example.com/announce",
7092            info: Info {
7093                length: data.len() as u64,
7094                name: "private_test",
7095                piece_length: 16384,
7096                pieces: &pieces,
7097                private: 1,
7098            },
7099        };
7100
7101        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7102        let meta = torrent_from_bytes(&bytes).unwrap();
7103        assert_eq!(meta.info.private, Some(1));
7104
7105        let storage = make_storage(&data, 16384);
7106        let config = test_config();
7107
7108        let (atx, amask) = test_alert_channel();
7109        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7110        let handle = TorrentHandle::from_torrent(
7111            meta,
7112            irontide_core::TorrentVersion::V1Only,
7113            None,
7114            dh,
7115            dm,
7116            config,
7117            test_dht_rx(),
7118            test_dht_rx(),
7119            None,
7120            None,
7121            crate::slot_tuner::SlotTuner::disabled(4),
7122            atx,
7123            amask,
7124            None,
7125            None,
7126            test_ban_manager(),
7127            test_ip_filter(),
7128            Arc::new(Vec::new()),
7129            None,
7130            None,
7131            Arc::new(crate::transport::NetworkFactory::tokio()),
7132            None, // M96: hash_pool
7133            Arc::new(crate::stats::SessionCounters::new()),
7134        )
7135        .await
7136        .unwrap();
7137
7138        let stats = handle.stats().await.unwrap();
7139        assert_eq!(stats.state, TorrentState::Downloading);
7140
7141        handle.shutdown().await.unwrap();
7142    }
7143
7144    // ---- Test 18: Magnet defers tracker announce ----
7145
7146    #[tokio::test]
7147    async fn magnet_no_tracker_before_metadata() {
7148        let magnet = Magnet {
7149            info_hashes: irontide_core::InfoHashes::v1_only(
7150                Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
7151            ),
7152            display_name: Some("magnet test".into()),
7153            trackers: vec![],
7154            peers: vec![],
7155            selected_files: None,
7156        };
7157
7158        let (atx, amask) = test_alert_channel();
7159        let (dm, _dj) = test_disk_manager();
7160        let handle = TorrentHandle::from_magnet(
7161            magnet,
7162            dm,
7163            test_config(),
7164            test_dht_rx(),
7165            test_dht_rx(),
7166            None,
7167            None,
7168            crate::slot_tuner::SlotTuner::disabled(4),
7169            atx,
7170            amask,
7171            None,
7172            None,
7173            test_ban_manager(),
7174            test_ip_filter(),
7175            Arc::new(Vec::new()),
7176            None,
7177            None,
7178            Arc::new(crate::transport::NetworkFactory::tokio()),
7179            None, // M96: hash_pool
7180            Arc::new(crate::stats::SessionCounters::new()),
7181        )
7182        .await
7183        .unwrap();
7184
7185        let stats = handle.stats().await.unwrap();
7186        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7187
7188        // With no trackers configured, no announces happen regardless of state.
7189        // Note: tracker announces ARE now allowed during FetchingMetadata for
7190        // magnets with &tr= URLs (needed to discover peers before metadata).
7191        tokio::time::sleep(Duration::from_millis(50)).await;
7192
7193        handle.shutdown().await.unwrap();
7194    }
7195
7196    // ---- Test 19: Pause and resume ----
7197
7198    #[tokio::test]
7199    async fn pause_and_resume() {
7200        let data = vec![0xEEu8; 32768];
7201        let meta = make_test_torrent(&data, 16384);
7202        let storage = make_storage(&data, 16384);
7203        let config = test_config();
7204        let (atx, amask) = test_alert_channel();
7205        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7206        let handle = TorrentHandle::from_torrent(
7207            meta,
7208            irontide_core::TorrentVersion::V1Only,
7209            None,
7210            dh,
7211            dm,
7212            config,
7213            test_dht_rx(),
7214            test_dht_rx(),
7215            None,
7216            None,
7217            crate::slot_tuner::SlotTuner::disabled(4),
7218            atx,
7219            amask,
7220            None,
7221            None,
7222            test_ban_manager(),
7223            test_ip_filter(),
7224            Arc::new(Vec::new()),
7225            None,
7226            None,
7227            Arc::new(crate::transport::NetworkFactory::tokio()),
7228            None, // M96: hash_pool
7229            Arc::new(crate::stats::SessionCounters::new()),
7230        )
7231        .await
7232        .unwrap();
7233
7234        let stats = handle.stats().await.unwrap();
7235        assert_eq!(stats.state, TorrentState::Downloading);
7236
7237        handle.pause().await.unwrap();
7238        tokio::time::sleep(Duration::from_millis(50)).await;
7239        let stats = handle.stats().await.unwrap();
7240        assert_eq!(stats.state, TorrentState::Paused);
7241
7242        handle.resume().await.unwrap();
7243        tokio::time::sleep(Duration::from_millis(50)).await;
7244        let stats = handle.stats().await.unwrap();
7245        assert_eq!(stats.state, TorrentState::Downloading);
7246
7247        handle.shutdown().await.unwrap();
7248    }
7249
7250    // ---- Test 20: Pause already paused is noop ----
7251
7252    #[tokio::test]
7253    async fn pause_already_paused_is_noop() {
7254        let data = vec![0xEEu8; 32768];
7255        let meta = make_test_torrent(&data, 16384);
7256        let storage = make_storage(&data, 16384);
7257        let config = test_config();
7258        let (atx, amask) = test_alert_channel();
7259        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7260        let handle = TorrentHandle::from_torrent(
7261            meta,
7262            irontide_core::TorrentVersion::V1Only,
7263            None,
7264            dh,
7265            dm,
7266            config,
7267            test_dht_rx(),
7268            test_dht_rx(),
7269            None,
7270            None,
7271            crate::slot_tuner::SlotTuner::disabled(4),
7272            atx,
7273            amask,
7274            None,
7275            None,
7276            test_ban_manager(),
7277            test_ip_filter(),
7278            Arc::new(Vec::new()),
7279            None,
7280            None,
7281            Arc::new(crate::transport::NetworkFactory::tokio()),
7282            None, // M96: hash_pool
7283            Arc::new(crate::stats::SessionCounters::new()),
7284        )
7285        .await
7286        .unwrap();
7287
7288        handle.pause().await.unwrap();
7289        tokio::time::sleep(Duration::from_millis(50)).await;
7290        handle.pause().await.unwrap(); // double pause is fine
7291        tokio::time::sleep(Duration::from_millis(50)).await;
7292        let stats = handle.stats().await.unwrap();
7293        assert_eq!(stats.state, TorrentState::Paused);
7294
7295        handle.shutdown().await.unwrap();
7296    }
7297
7298    // ---- Test 21: Incoming request served from storage ----
7299    //
7300    // Phase 1: Mock seeder feeds piece 0 to the torrent so it becomes verified.
7301    // Phase 2: Mock leecher connects and requests piece 0, verifying upload pipeline.
7302
7303    #[tokio::test]
7304    async fn incoming_request_served_from_storage() {
7305        let data = vec![0xABu8; 16384];
7306        let meta = make_test_torrent(&data, 16384);
7307        let info_hash = meta.info_hash;
7308        let storage = make_storage(&data, 16384);
7309
7310        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7311        let listen_addr = listener.local_addr().unwrap();
7312        drop(listener);
7313
7314        let config = TorrentConfig {
7315            listen_port: listen_addr.port(),
7316            ..test_config()
7317        };
7318
7319        let (atx, amask) = test_alert_channel();
7320        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7321        let handle = TorrentHandle::from_torrent(
7322            meta,
7323            irontide_core::TorrentVersion::V1Only,
7324            None,
7325            dh,
7326            dm,
7327            config,
7328            test_dht_rx(),
7329            test_dht_rx(),
7330            None,
7331            None,
7332            crate::slot_tuner::SlotTuner::disabled(4),
7333            atx,
7334            amask,
7335            None,
7336            None,
7337            test_ban_manager(),
7338            test_ip_filter(),
7339            Arc::new(Vec::new()),
7340            None,
7341            None,
7342            Arc::new(crate::transport::NetworkFactory::tokio()),
7343            None, // M96: hash_pool
7344            Arc::new(crate::stats::SessionCounters::new()),
7345        )
7346        .await
7347        .unwrap();
7348
7349        tokio::time::sleep(Duration::from_millis(50)).await;
7350
7351        // Phase 1: Seed the torrent with piece 0
7352        let seed_data = data.clone();
7353        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7354        let seeder_task = tokio::spawn(async move {
7355            let (reader, writer) = tokio::io::split(seed_stream);
7356            let mut writer = writer;
7357            let mut reader = reader;
7358
7359            let hs = Handshake::new(
7360                info_hash,
7361                Id20::from_hex("6666666666666666666666666666666666666666").unwrap(),
7362            );
7363            writer.write_all(&hs.to_bytes()).await.unwrap();
7364            writer.flush().await.unwrap();
7365            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7366            reader.read_exact(&mut hs_buf).await.unwrap();
7367
7368            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7369            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7370
7371            let _msg = framed_read.next().await; // ext handshake
7372            let ext_hs = ExtHandshake::new();
7373            let payload = ext_hs.to_bytes().unwrap();
7374            framed_write
7375                .send(Message::Extended { ext_id: 0, payload })
7376                .await
7377                .unwrap();
7378
7379            // Send bitfield + unchoke
7380            let mut bf = Bitfield::new(1);
7381            bf.set(0);
7382            framed_write
7383                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7384                .await
7385                .unwrap();
7386            framed_write.send(Message::Unchoke).await.unwrap();
7387
7388            // Respond to requests
7389            while let Some(Ok(msg)) = framed_read.next().await {
7390                if let Message::Request {
7391                    index,
7392                    begin,
7393                    length,
7394                } = msg
7395                {
7396                    let start = begin as usize;
7397                    let end = start + length as usize;
7398                    framed_write
7399                        .send(Message::Piece {
7400                            index,
7401                            begin,
7402                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7403                            data_1: Bytes::new(),
7404                        })
7405                        .await
7406                        .unwrap();
7407                }
7408            }
7409        });
7410
7411        // Wait for download to complete
7412        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7413        loop {
7414            tokio::time::sleep(Duration::from_millis(100)).await;
7415            let stats = handle.stats().await.unwrap();
7416            if stats.pieces_have == 1 {
7417                break;
7418            }
7419            assert!(
7420                tokio::time::Instant::now() <= deadline,
7421                "piece download did not complete within 5s"
7422            );
7423        }
7424
7425        // Phase 2: Connect a mock leecher to request piece 0 back
7426        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7427        let expected_data = data.clone();
7428        let leecher_task = tokio::spawn(async move {
7429            let (reader, writer) = tokio::io::split(leech_stream);
7430            let mut writer = writer;
7431            let mut reader = reader;
7432
7433            let hs = Handshake::new(
7434                info_hash,
7435                Id20::from_hex("7777777777777777777777777777777777777777").unwrap(),
7436            );
7437            writer.write_all(&hs.to_bytes()).await.unwrap();
7438            writer.flush().await.unwrap();
7439            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7440            reader.read_exact(&mut hs_buf).await.unwrap();
7441
7442            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7443            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7444
7445            let _msg = framed_read.next().await; // ext handshake
7446            let ext_hs = ExtHandshake::new();
7447            let payload = ext_hs.to_bytes().unwrap();
7448            framed_write
7449                .send(Message::Extended { ext_id: 0, payload })
7450                .await
7451                .unwrap();
7452
7453            // Send Interested and wait for Unchoke
7454            framed_write.send(Message::Interested).await.unwrap();
7455
7456            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7457            loop {
7458                tokio::select! {
7459                    msg = framed_read.next() => {
7460                        match msg {
7461                            Some(Ok(Message::Unchoke)) => { break; }
7462                            Some(Ok(_)) => {}
7463                            _ => panic!("connection closed before unchoke"),
7464                        }
7465                    }
7466                    () = tokio::time::sleep_until(deadline) => {
7467                        panic!("timed out waiting for unchoke");
7468                    }
7469                }
7470            }
7471
7472            // Request piece 0
7473            framed_write
7474                .send(Message::Request {
7475                    index: 0,
7476                    begin: 0,
7477                    length: 16384,
7478                })
7479                .await
7480                .unwrap();
7481
7482            // Read Piece response
7483            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7484            loop {
7485                tokio::select! {
7486                    msg = framed_read.next() => {
7487                        match msg {
7488                            Some(Ok(Message::Piece { index, begin, data_0, data_1 })) => {
7489                                assert_eq!(index, 0);
7490                                assert_eq!(begin, 0);
7491                                let _ = &data_1; // empty after wire round-trip
7492                                assert_eq!(data_0.as_ref(), expected_data.as_slice());
7493                                return; // success
7494                            }
7495                            Some(Ok(_)) => {}
7496                            Some(Err(e)) => panic!("error reading: {e}"),
7497                            None => panic!("connection closed before piece"),
7498                        }
7499                    }
7500                    () = tokio::time::sleep_until(deadline) => {
7501                        panic!("timed out waiting for piece data");
7502                    }
7503                }
7504            }
7505        });
7506
7507        // Wait for leecher to complete
7508        let result = tokio::time::timeout(Duration::from_secs(20), leecher_task).await;
7509        match result {
7510            Ok(Ok(())) => {}
7511            Ok(Err(e)) => panic!("leecher task panicked: {e}"),
7512            Err(elapsed) => panic!("test timed out after {elapsed}"),
7513        }
7514
7515        // Verify uploaded bytes
7516        let stats = handle.stats().await.unwrap();
7517        assert!(
7518            stats.uploaded > 0,
7519            "expected uploaded > 0, got {}",
7520            stats.uploaded
7521        );
7522
7523        handle.shutdown().await.unwrap();
7524        seeder_task.abort();
7525    }
7526
7527    // ---- Test 22: Seed ratio limit stops torrent ----
7528
7529    #[tokio::test]
7530    async fn seed_ratio_limit_stops_torrent() {
7531        // 1-piece torrent, ratio limit = 1.0
7532        // After downloading 16384 bytes and uploading 16384 bytes, ratio = 1.0 → stop
7533        let data = vec![0xCCu8; 16384];
7534        let meta = make_test_torrent(&data, 16384);
7535        let info_hash = meta.info_hash;
7536        let storage = make_storage(&data, 16384);
7537
7538        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7539        let listen_addr = listener.local_addr().unwrap();
7540        drop(listener);
7541
7542        let config = TorrentConfig {
7543            listen_port: listen_addr.port(),
7544            seed_ratio_limit: Some(1.0),
7545            ..test_config()
7546        };
7547
7548        let (atx, amask) = test_alert_channel();
7549        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7550        let handle = TorrentHandle::from_torrent(
7551            meta,
7552            irontide_core::TorrentVersion::V1Only,
7553            None,
7554            dh,
7555            dm,
7556            config,
7557            test_dht_rx(),
7558            test_dht_rx(),
7559            None,
7560            None,
7561            crate::slot_tuner::SlotTuner::disabled(4),
7562            atx,
7563            amask,
7564            None,
7565            None,
7566            test_ban_manager(),
7567            test_ip_filter(),
7568            Arc::new(Vec::new()),
7569            None,
7570            None,
7571            Arc::new(crate::transport::NetworkFactory::tokio()),
7572            None, // M96: hash_pool
7573            Arc::new(crate::stats::SessionCounters::new()),
7574        )
7575        .await
7576        .unwrap();
7577
7578        tokio::time::sleep(Duration::from_millis(50)).await;
7579
7580        // Phase 1: Seed the torrent with piece 0
7581        let seed_data = data.clone();
7582        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7583        let seeder_task = tokio::spawn(async move {
7584            let (reader, writer) = tokio::io::split(seed_stream);
7585            let mut writer = writer;
7586            let mut reader = reader;
7587
7588            let hs = Handshake::new(
7589                info_hash,
7590                Id20::from_hex("8888888888888888888888888888888888888888").unwrap(),
7591            );
7592            writer.write_all(&hs.to_bytes()).await.unwrap();
7593            writer.flush().await.unwrap();
7594            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7595            reader.read_exact(&mut hs_buf).await.unwrap();
7596
7597            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7598            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7599
7600            let _msg = framed_read.next().await;
7601            let ext_hs = ExtHandshake::new();
7602            let payload = ext_hs.to_bytes().unwrap();
7603            framed_write
7604                .send(Message::Extended { ext_id: 0, payload })
7605                .await
7606                .unwrap();
7607
7608            let mut bf = Bitfield::new(1);
7609            bf.set(0);
7610            framed_write
7611                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7612                .await
7613                .unwrap();
7614            framed_write.send(Message::Unchoke).await.unwrap();
7615
7616            while let Some(Ok(msg)) = framed_read.next().await {
7617                if let Message::Request {
7618                    index,
7619                    begin,
7620                    length,
7621                } = msg
7622                {
7623                    let start = begin as usize;
7624                    let end = start + length as usize;
7625                    framed_write
7626                        .send(Message::Piece {
7627                            index,
7628                            begin,
7629                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7630                            data_1: Bytes::new(),
7631                        })
7632                        .await
7633                        .unwrap();
7634                }
7635            }
7636        });
7637
7638        // Wait for download to complete (transitions to Seeding)
7639        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7640        loop {
7641            tokio::time::sleep(Duration::from_millis(100)).await;
7642            let stats = handle.stats().await.unwrap();
7643            if stats.state == TorrentState::Seeding {
7644                break;
7645            }
7646            assert!(
7647                tokio::time::Instant::now() <= deadline,
7648                "download did not complete within 5s"
7649            );
7650        }
7651
7652        // Phase 2: Connect leecher to request piece 0 — this should trigger ratio limit
7653        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7654        let leecher_task = tokio::spawn(async move {
7655            let (reader, writer) = tokio::io::split(leech_stream);
7656            let mut writer = writer;
7657            let mut reader = reader;
7658
7659            let hs = Handshake::new(
7660                info_hash,
7661                Id20::from_hex("9999999999999999999999999999999999999999").unwrap(),
7662            );
7663            writer.write_all(&hs.to_bytes()).await.unwrap();
7664            writer.flush().await.unwrap();
7665            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7666            reader.read_exact(&mut hs_buf).await.unwrap();
7667
7668            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7669            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7670
7671            let _msg = framed_read.next().await;
7672            let ext_hs = ExtHandshake::new();
7673            let payload = ext_hs.to_bytes().unwrap();
7674            framed_write
7675                .send(Message::Extended { ext_id: 0, payload })
7676                .await
7677                .unwrap();
7678
7679            framed_write.send(Message::Interested).await.unwrap();
7680
7681            // Wait for unchoke
7682            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7683            loop {
7684                tokio::select! {
7685                    msg = framed_read.next() => {
7686                        match msg {
7687                            Some(Ok(Message::Unchoke)) => break,
7688                            Some(Ok(_)) => {}
7689                            _ => return, // connection may close due to ratio shutdown
7690                        }
7691                    }
7692                    () = tokio::time::sleep_until(deadline) => return,
7693                }
7694            }
7695
7696            // Request piece 0
7697            framed_write
7698                .send(Message::Request {
7699                    index: 0,
7700                    begin: 0,
7701                    length: 16384,
7702                })
7703                .await
7704                .unwrap();
7705
7706            // Read until connection closes (the torrent may stop and disconnect us)
7707            while let Some(Ok(_msg)) = framed_read.next().await {}
7708        });
7709
7710        // Wait for state to become Stopped
7711        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
7712        loop {
7713            tokio::time::sleep(Duration::from_millis(100)).await;
7714            let stats = handle.stats().await.unwrap();
7715            if stats.state == TorrentState::Stopped {
7716                assert!(
7717                    stats.uploaded >= 16384,
7718                    "expected uploaded >= 16384, got {}",
7719                    stats.uploaded
7720                );
7721                break;
7722            }
7723            if tokio::time::Instant::now() > deadline {
7724                let stats = handle.stats().await.unwrap();
7725                panic!(
7726                    "expected Stopped, got {:?}, uploaded={}, downloaded={}",
7727                    stats.state, stats.uploaded, stats.downloaded
7728                );
7729            }
7730        }
7731
7732        handle.shutdown().await.unwrap();
7733        seeder_task.abort();
7734        leecher_task.abort();
7735    }
7736
7737    // ---- Test 23: Resume with seeded storage starts as seeder ----
7738
7739    #[tokio::test]
7740    async fn resume_with_seeded_storage() {
7741        let data = vec![0xDDu8; 32768]; // 2 pieces
7742        let meta = make_test_torrent(&data, 16384);
7743        let storage = make_seeded_storage(&data, 16384);
7744        let config = test_config();
7745
7746        let (atx, amask) = test_alert_channel();
7747        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7748        let handle = TorrentHandle::from_torrent(
7749            meta,
7750            irontide_core::TorrentVersion::V1Only,
7751            None,
7752            dh,
7753            dm,
7754            config,
7755            test_dht_rx(),
7756            test_dht_rx(),
7757            None,
7758            None,
7759            crate::slot_tuner::SlotTuner::disabled(4),
7760            atx,
7761            amask,
7762            None,
7763            None,
7764            test_ban_manager(),
7765            test_ip_filter(),
7766            Arc::new(Vec::new()),
7767            None,
7768            None,
7769            Arc::new(crate::transport::NetworkFactory::tokio()),
7770            None, // M96: hash_pool
7771            Arc::new(crate::stats::SessionCounters::new()),
7772        )
7773        .await
7774        .unwrap();
7775
7776        // Give the actor time to verify existing pieces
7777        tokio::time::sleep(Duration::from_millis(100)).await;
7778
7779        let stats = handle.stats().await.unwrap();
7780        assert_eq!(
7781            stats.state,
7782            TorrentState::Seeding,
7783            "should start as seeder with all pieces verified"
7784        );
7785        assert_eq!(stats.pieces_have, 2);
7786        assert_eq!(stats.pieces_total, 2);
7787
7788        handle.shutdown().await.unwrap();
7789    }
7790
7791    // ---- Test: save_resume_data captures state ----
7792
7793    #[tokio::test]
7794    async fn save_resume_data_captures_state() {
7795        let data = vec![0xAB; 32768];
7796        let meta = make_test_torrent(&data, 16384);
7797        let info_hash = meta.info_hash;
7798        let storage = make_storage(&data, 16384);
7799        let config = test_config();
7800
7801        let (atx, amask) = test_alert_channel();
7802        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7803        let handle = TorrentHandle::from_torrent(
7804            meta,
7805            irontide_core::TorrentVersion::V1Only,
7806            None,
7807            dh,
7808            dm,
7809            config,
7810            test_dht_rx(),
7811            test_dht_rx(),
7812            None,
7813            None,
7814            crate::slot_tuner::SlotTuner::disabled(4),
7815            atx,
7816            amask,
7817            None,
7818            None,
7819            test_ban_manager(),
7820            test_ip_filter(),
7821            Arc::new(Vec::new()),
7822            None,
7823            None,
7824            Arc::new(crate::transport::NetworkFactory::tokio()),
7825            None, // M96: hash_pool
7826            Arc::new(crate::stats::SessionCounters::new()),
7827        )
7828        .await
7829        .unwrap();
7830
7831        // Give actor time to start
7832        tokio::time::sleep(Duration::from_millis(50)).await;
7833
7834        let rd = handle.save_resume_data().await.unwrap();
7835
7836        assert_eq!(rd.file_format, "libtorrent resume file");
7837        assert_eq!(rd.file_version, 1);
7838        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
7839        assert_eq!(rd.name, "test");
7840        assert_eq!(rd.save_path, "/tmp");
7841        assert_eq!(rd.paused, 0);
7842        // No pieces downloaded yet — bitfield should be all zeros
7843        assert!(!rd.pieces.is_empty());
7844        // Stats should be zero for a freshly started torrent with no peers
7845        assert_eq!(rd.total_uploaded, 0);
7846        assert_eq!(rd.total_downloaded, 0);
7847
7848        handle.shutdown().await.unwrap();
7849    }
7850
7851    // ---- Test: save_resume_data for seeder ----
7852
7853    #[tokio::test]
7854    async fn save_resume_data_seeder() {
7855        let data = vec![0xCD; 32768];
7856        let meta = make_test_torrent(&data, 16384);
7857        let info_hash = meta.info_hash;
7858        let storage = make_seeded_storage(&data, 16384);
7859        let config = test_config();
7860
7861        let (atx, amask) = test_alert_channel();
7862        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7863        let handle = TorrentHandle::from_torrent(
7864            meta,
7865            irontide_core::TorrentVersion::V1Only,
7866            None,
7867            dh,
7868            dm,
7869            config,
7870            test_dht_rx(),
7871            test_dht_rx(),
7872            None,
7873            None,
7874            crate::slot_tuner::SlotTuner::disabled(4),
7875            atx,
7876            amask,
7877            None,
7878            None,
7879            test_ban_manager(),
7880            test_ip_filter(),
7881            Arc::new(Vec::new()),
7882            None,
7883            None,
7884            Arc::new(crate::transport::NetworkFactory::tokio()),
7885            None, // M96: hash_pool
7886            Arc::new(crate::stats::SessionCounters::new()),
7887        )
7888        .await
7889        .unwrap();
7890
7891        // Give actor time to verify pieces and switch to seeding
7892        tokio::time::sleep(Duration::from_millis(100)).await;
7893
7894        let rd = handle.save_resume_data().await.unwrap();
7895
7896        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
7897        assert_eq!(rd.name, "test");
7898        assert_eq!(rd.seed_mode, 1, "seeder should have seed_mode=1");
7899        assert_eq!(rd.paused, 0);
7900        // All pieces should be marked in the bitfield
7901        // 2 pieces -> 1 byte, top 2 bits set = 0b1100_0000 = 0xC0
7902        assert_eq!(rd.pieces.len(), 1);
7903        assert_eq!(
7904            rd.pieces[0] & 0xC0,
7905            0xC0,
7906            "both pieces should be marked complete"
7907        );
7908
7909        handle.shutdown().await.unwrap();
7910    }
7911
7912    // ---- Test: save_resume_data for paused torrent ----
7913
7914    #[tokio::test]
7915    async fn save_resume_data_paused() {
7916        let data = vec![0xEF; 16384];
7917        let meta = make_test_torrent(&data, 16384);
7918        let storage = make_storage(&data, 16384);
7919        let config = test_config();
7920
7921        let (atx, amask) = test_alert_channel();
7922        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7923        let handle = TorrentHandle::from_torrent(
7924            meta,
7925            irontide_core::TorrentVersion::V1Only,
7926            None,
7927            dh,
7928            dm,
7929            config,
7930            test_dht_rx(),
7931            test_dht_rx(),
7932            None,
7933            None,
7934            crate::slot_tuner::SlotTuner::disabled(4),
7935            atx,
7936            amask,
7937            None,
7938            None,
7939            test_ban_manager(),
7940            test_ip_filter(),
7941            Arc::new(Vec::new()),
7942            None,
7943            None,
7944            Arc::new(crate::transport::NetworkFactory::tokio()),
7945            None, // M96: hash_pool
7946            Arc::new(crate::stats::SessionCounters::new()),
7947        )
7948        .await
7949        .unwrap();
7950
7951        tokio::time::sleep(Duration::from_millis(50)).await;
7952        handle.pause().await.unwrap();
7953        tokio::time::sleep(Duration::from_millis(50)).await;
7954
7955        let rd = handle.save_resume_data().await.unwrap();
7956        assert_eq!(rd.paused, 1, "paused torrent should have paused=1");
7957        assert_eq!(rd.seed_mode, 0);
7958
7959        handle.shutdown().await.unwrap();
7960    }
7961
7962    // ---- Test: set_file_priority and read back ----
7963
7964    #[tokio::test]
7965    async fn set_file_priority_and_read_back() {
7966        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
7967        let mut torrent_bytes = b"d4:info".to_vec();
7968        torrent_bytes.extend_from_slice(info_bytes);
7969        torrent_bytes.push(b'e');
7970
7971        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
7972        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
7973        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
7974        let config = TorrentConfig {
7975            listen_port: 0,
7976            ..Default::default()
7977        };
7978
7979        let (atx, amask) = test_alert_channel();
7980        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7981        let handle = TorrentHandle::from_torrent(
7982            meta,
7983            irontide_core::TorrentVersion::V1Only,
7984            None,
7985            dh,
7986            dm,
7987            config,
7988            test_dht_rx(),
7989            test_dht_rx(),
7990            None,
7991            None,
7992            crate::slot_tuner::SlotTuner::disabled(4),
7993            atx,
7994            amask,
7995            None,
7996            None,
7997            test_ban_manager(),
7998            test_ip_filter(),
7999            Arc::new(Vec::new()),
8000            None,
8001            None,
8002            Arc::new(crate::transport::NetworkFactory::tokio()),
8003            None, // M96: hash_pool
8004            Arc::new(crate::stats::SessionCounters::new()),
8005        )
8006        .await
8007        .unwrap();
8008
8009        // Default priorities should all be Normal
8010        let prios = handle.file_priorities().await.unwrap();
8011        assert_eq!(prios.len(), 2);
8012        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8013
8014        // Set file 0 to Skip
8015        handle
8016            .set_file_priority(0, FilePriority::Skip)
8017            .await
8018            .unwrap();
8019
8020        let prios = handle.file_priorities().await.unwrap();
8021        assert_eq!(prios[0], FilePriority::Skip);
8022        assert_eq!(prios[1], FilePriority::Normal);
8023
8024        // Invalid index should error
8025        let result = handle.set_file_priority(99, FilePriority::High).await;
8026        assert!(result.is_err());
8027
8028        handle.shutdown().await.unwrap();
8029        tokio::time::sleep(Duration::from_millis(50)).await;
8030    }
8031
8032    #[tokio::test]
8033    async fn resume_data_preserves_file_priorities() {
8034        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8035        let mut torrent_bytes = b"d4:info".to_vec();
8036        torrent_bytes.extend_from_slice(info_bytes);
8037        torrent_bytes.push(b'e');
8038
8039        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8040        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8041        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8042        let config = TorrentConfig {
8043            listen_port: 0,
8044            ..Default::default()
8045        };
8046
8047        let (atx, amask) = test_alert_channel();
8048        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8049        let handle = TorrentHandle::from_torrent(
8050            meta,
8051            irontide_core::TorrentVersion::V1Only,
8052            None,
8053            dh,
8054            dm,
8055            config,
8056            test_dht_rx(),
8057            test_dht_rx(),
8058            None,
8059            None,
8060            crate::slot_tuner::SlotTuner::disabled(4),
8061            atx,
8062            amask,
8063            None,
8064            None,
8065            test_ban_manager(),
8066            test_ip_filter(),
8067            Arc::new(Vec::new()),
8068            None,
8069            None,
8070            Arc::new(crate::transport::NetworkFactory::tokio()),
8071            None, // M96: hash_pool
8072            Arc::new(crate::stats::SessionCounters::new()),
8073        )
8074        .await
8075        .unwrap();
8076
8077        // Set file priorities
8078        handle
8079            .set_file_priority(0, FilePriority::High)
8080            .await
8081            .unwrap();
8082        handle
8083            .set_file_priority(1, FilePriority::Skip)
8084            .await
8085            .unwrap();
8086
8087        // Save resume data
8088        let rd = handle.save_resume_data().await.unwrap();
8089        assert_eq!(rd.file_priority, vec![7, 0]); // High=7, Skip=0
8090
8091        // Verify bencode round-trip
8092        let encoded = irontide_bencode::to_bytes(&rd).unwrap();
8093        let decoded: irontide_core::FastResumeData =
8094            irontide_bencode::from_bytes(&encoded).unwrap();
8095        assert_eq!(decoded.file_priority, vec![7, 0]);
8096
8097        handle.shutdown().await.unwrap();
8098        tokio::time::sleep(Duration::from_millis(50)).await;
8099    }
8100
8101    // ---- Rate limiting integration tests (M14) ----
8102
8103    #[tokio::test]
8104    async fn upload_rate_limiting_caps_throughput() {
8105        // Test that per-torrent upload rate limiting gates serve_incoming_requests.
8106        // We use a very low rate (1 KB/s) so the 16 KB piece requires ~16 seconds.
8107        // We verify: 1) piece does NOT arrive within 200ms (bucket too small),
8108        //            2) the torrent actor is alive and functional.
8109        let data = vec![0xAB; 16384]; // 1 piece
8110        let meta = make_test_torrent(&data, 16384);
8111        let info_hash = meta.info_hash;
8112        let storage = make_seeded_storage(&data, 16384);
8113
8114        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8115        let listen_addr = listener.local_addr().unwrap();
8116
8117        let config = TorrentConfig {
8118            listen_port: listen_addr.port(),
8119            upload_rate_limit: 1024, // 1 KB/s — way too slow for 16 KB chunk
8120            ..test_config()
8121        };
8122
8123        drop(listener);
8124        let (atx, amask) = test_alert_channel();
8125        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8126        let handle = TorrentHandle::from_torrent(
8127            meta,
8128            irontide_core::TorrentVersion::V1Only,
8129            None,
8130            dh,
8131            dm,
8132            config,
8133            test_dht_rx(),
8134            test_dht_rx(),
8135            None,
8136            None,
8137            crate::slot_tuner::SlotTuner::disabled(4),
8138            atx,
8139            amask,
8140            None,
8141            None,
8142            test_ban_manager(),
8143            test_ip_filter(),
8144            Arc::new(Vec::new()),
8145            None,
8146            None,
8147            Arc::new(crate::transport::NetworkFactory::tokio()),
8148            None, // M96: hash_pool
8149            Arc::new(crate::stats::SessionCounters::new()),
8150        )
8151        .await
8152        .unwrap();
8153
8154        tokio::time::sleep(Duration::from_millis(50)).await;
8155
8156        // Connect mock leecher (raw handshake + framed messages)
8157        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8158        let (reader, writer) = tokio::io::split(stream);
8159        let mut writer = writer;
8160        let mut reader = reader;
8161
8162        let hs = Handshake::new(
8163            info_hash,
8164            Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8165        );
8166        writer.write_all(&hs.to_bytes()).await.unwrap();
8167        writer.flush().await.unwrap();
8168        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8169        reader.read_exact(&mut hs_buf).await.unwrap();
8170
8171        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8172        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8173
8174        // Read ext handshake + bitfield
8175        let _msg = framed_read.next().await;
8176        let ext_hs = ExtHandshake::new();
8177        let payload = ext_hs.to_bytes().unwrap();
8178        framed_write
8179            .send(Message::Extended { ext_id: 0, payload })
8180            .await
8181            .unwrap();
8182
8183        // Read the bitfield
8184        let _bf_msg = framed_read.next().await;
8185
8186        // Express interest
8187        framed_write.send(Message::Interested).await.unwrap();
8188
8189        // Wait for unchoke
8190        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
8191        loop {
8192            tokio::select! {
8193                msg = framed_read.next() => {
8194                    match msg {
8195                        Some(Ok(Message::Unchoke)) => break,
8196                        Some(Ok(_)) => {}
8197                        _ => panic!("connection closed before unchoke"),
8198                    }
8199                }
8200                () = tokio::time::sleep_until(deadline) => {
8201                    panic!("timed out waiting for unchoke");
8202                }
8203            }
8204        }
8205
8206        // Request piece 0
8207        framed_write
8208            .send(Message::Request {
8209                index: 0,
8210                begin: 0,
8211                length: 16384,
8212            })
8213            .await
8214            .unwrap();
8215
8216        // At 1 KB/s, the bucket accumulates ~100 bytes per 100ms tick (max burst = 1024).
8217        // A 16 KB chunk needs 16384 tokens, so it should NOT be served quickly.
8218        // We wait 2 seconds — at 1 KB/s we'd have at most 2 KB, still < 16 KB.
8219        let mut got_piece = false;
8220        if let Ok(true) = tokio::time::timeout(Duration::from_secs(2), async {
8221            loop {
8222                match framed_read.next().await {
8223                    Some(Ok(Message::Piece { .. })) => return true,
8224                    Some(Ok(_)) => {}
8225                    _ => return false,
8226                }
8227            }
8228        })
8229        .await
8230        {
8231            got_piece = true;
8232        }
8233
8234        // Piece should NOT have arrived in 2 seconds (would need 16s at 1 KB/s)
8235        assert!(
8236            !got_piece,
8237            "piece should be delayed by rate limiter (1 KB/s for 16 KB chunk)"
8238        );
8239
8240        // Verify actor is still alive
8241        let stats = handle.stats().await.unwrap();
8242        assert_eq!(stats.uploaded, 0); // nothing served yet
8243
8244        handle.shutdown().await.unwrap();
8245    }
8246
8247    #[tokio::test]
8248    async fn unlimited_rate_has_no_effect() {
8249        // Default config (rate = 0) should behave identically to pre-M14
8250        let data = vec![0xAB; 32768];
8251        let meta = make_test_torrent(&data, 16384);
8252        let storage = make_storage(&data, 16384);
8253        let config = test_config();
8254
8255        // Rate limits are 0 (unlimited) by default
8256        assert_eq!(config.upload_rate_limit, 0);
8257        assert_eq!(config.download_rate_limit, 0);
8258
8259        let (atx, amask) = test_alert_channel();
8260        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8261        let handle = TorrentHandle::from_torrent(
8262            meta,
8263            irontide_core::TorrentVersion::V1Only,
8264            None,
8265            dh,
8266            dm,
8267            config,
8268            test_dht_rx(),
8269            test_dht_rx(),
8270            None,
8271            None,
8272            crate::slot_tuner::SlotTuner::disabled(4),
8273            atx,
8274            amask,
8275            None,
8276            None,
8277            test_ban_manager(),
8278            test_ip_filter(),
8279            Arc::new(Vec::new()),
8280            None,
8281            None,
8282            Arc::new(crate::transport::NetworkFactory::tokio()),
8283            None, // M96: hash_pool
8284            Arc::new(crate::stats::SessionCounters::new()),
8285        )
8286        .await
8287        .unwrap();
8288
8289        let stats = handle.stats().await.unwrap();
8290        assert_eq!(stats.state, TorrentState::Downloading);
8291        assert_eq!(stats.pieces_total, 2);
8292
8293        handle.shutdown().await.unwrap();
8294    }
8295
8296    #[tokio::test]
8297    async fn download_rate_limiting_throttles_requests() {
8298        // Test that download_rate_limit prevents sending requests when budget exhausted.
8299        // With 1 KB/s limit and 16 KB chunks, budget is exhausted almost immediately.
8300        let data = vec![0xAB; 32768];
8301        let meta = make_test_torrent(&data, 16384);
8302        let info_hash = meta.info_hash;
8303        let storage = make_storage(&data, 16384);
8304
8305        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8306        let listen_addr = listener.local_addr().unwrap();
8307
8308        let config = TorrentConfig {
8309            listen_port: listen_addr.port(),
8310            download_rate_limit: 1024, // Very low: 1 KB/s
8311            ..test_config()
8312        };
8313
8314        drop(listener);
8315        let (atx, amask) = test_alert_channel();
8316        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8317        let handle = TorrentHandle::from_torrent(
8318            meta,
8319            irontide_core::TorrentVersion::V1Only,
8320            None,
8321            dh,
8322            dm,
8323            config,
8324            test_dht_rx(),
8325            test_dht_rx(),
8326            None,
8327            None,
8328            crate::slot_tuner::SlotTuner::disabled(4),
8329            atx,
8330            amask,
8331            None,
8332            None,
8333            test_ban_manager(),
8334            test_ip_filter(),
8335            Arc::new(Vec::new()),
8336            None,
8337            None,
8338            Arc::new(crate::transport::NetworkFactory::tokio()),
8339            None, // M96: hash_pool
8340            Arc::new(crate::stats::SessionCounters::new()),
8341        )
8342        .await
8343        .unwrap();
8344
8345        tokio::time::sleep(Duration::from_millis(50)).await;
8346
8347        // Connect mock seeder
8348        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8349        let (reader, writer) = tokio::io::split(stream);
8350        let mut writer = writer;
8351        let mut reader = reader;
8352
8353        let hs = Handshake::new(
8354            info_hash,
8355            Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
8356        );
8357        writer.write_all(&hs.to_bytes()).await.unwrap();
8358        writer.flush().await.unwrap();
8359        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8360        reader.read_exact(&mut hs_buf).await.unwrap();
8361
8362        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8363        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8364
8365        // Read ext handshake
8366        let _msg = framed_read.next().await;
8367        let ext_hs = ExtHandshake::new();
8368        let payload = ext_hs.to_bytes().unwrap();
8369        framed_write
8370            .send(Message::Extended { ext_id: 0, payload })
8371            .await
8372            .unwrap();
8373
8374        // Send bitfield saying we have all pieces (act as seeder)
8375        let mut bf = Bitfield::new(2);
8376        bf.set(0);
8377        bf.set(1);
8378        framed_write
8379            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
8380            .await
8381            .unwrap();
8382
8383        // Unchoke the torrent
8384        framed_write.send(Message::Unchoke).await.unwrap();
8385
8386        // Count Request messages received within 500ms.
8387        // With 1 KB/s download limit, the bucket only accumulates ~50 bytes
8388        // per 100ms tick, far less than 16 KB needed for a full chunk request.
8389        let mut requests_received = 0u32;
8390        let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
8391        loop {
8392            match tokio::time::timeout(
8393                deadline.saturating_duration_since(tokio::time::Instant::now()),
8394                framed_read.next(),
8395            )
8396            .await
8397            {
8398                Ok(Some(Ok(Message::Request { .. }))) => {
8399                    requests_received += 1;
8400                }
8401                Ok(Some(Ok(_))) => {}
8402                _ => break,
8403            }
8404        }
8405
8406        let stats = handle.stats().await.unwrap();
8407        assert_eq!(stats.state, TorrentState::Downloading);
8408
8409        // With 1 KB/s download limit and 16 KB chunks, we should see very few
8410        // or no requests within 500ms (budget insufficient for even one chunk)
8411        assert!(
8412            requests_received <= 2,
8413            "with 1 KB/s limit, should get very few requests, got {requests_received}"
8414        );
8415
8416        handle.shutdown().await.unwrap();
8417    }
8418
8419    // ── Smart banning tests (M25) ────────────────────────────────────
8420
8421    #[test]
8422    fn piece_contributor_tracking() {
8423        use std::net::IpAddr;
8424        let mut contributors: HashMap<u32, HashSet<IpAddr>> = HashMap::new();
8425        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
8426        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
8427
8428        contributors.entry(0).or_default().insert(ip1);
8429        contributors.entry(0).or_default().insert(ip2);
8430        assert_eq!(contributors[&0].len(), 2);
8431        assert!(contributors[&0].contains(&ip1));
8432        assert!(contributors[&0].contains(&ip2));
8433
8434        // Clear on verify
8435        contributors.remove(&0);
8436        assert!(!contributors.contains_key(&0));
8437    }
8438
8439    #[test]
8440    fn parole_enter_on_hash_failure() {
8441        use crate::ban::ParoleState;
8442        use std::net::IpAddr;
8443
8444        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
8445        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
8446        let contributors = vec![ip1, ip2];
8447
8448        // Simulate entering parole
8449        let parole = ParoleState {
8450            original_contributors: contributors.into_iter().collect(),
8451            parole_peer: None,
8452        };
8453
8454        assert_eq!(parole.original_contributors.len(), 2);
8455        assert!(parole.original_contributors.contains(&ip1));
8456        assert!(parole.original_contributors.contains(&ip2));
8457        assert!(parole.parole_peer.is_none());
8458    }
8459
8460    #[test]
8461    fn parole_success_strikes_originals() {
8462        use crate::ban::{BanConfig, BanManager, ParoleState};
8463        use std::net::IpAddr;
8464
8465        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
8466        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
8467        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
8468
8469        let mut mgr = BanManager::new(BanConfig {
8470            max_failures: 2,
8471            use_parole: true,
8472        });
8473
8474        let parole = ParoleState {
8475            original_contributors: [ip1, ip2].into_iter().collect(),
8476            parole_peer: Some(parole_ip),
8477        };
8478
8479        // Simulate parole success: strike all originals
8480        for ip in &parole.original_contributors {
8481            mgr.record_strike(*ip);
8482        }
8483
8484        assert_eq!(*mgr.strikes_map().get(&ip1).unwrap(), 1);
8485        assert_eq!(*mgr.strikes_map().get(&ip2).unwrap(), 1);
8486        // Parole peer should not be struck
8487        assert!(!mgr.strikes_map().contains_key(&parole_ip));
8488
8489        // Second strike bans them
8490        for ip in &parole.original_contributors {
8491            mgr.record_strike(*ip);
8492        }
8493        assert!(mgr.is_banned(&ip1));
8494        assert!(mgr.is_banned(&ip2));
8495    }
8496
8497    #[test]
8498    fn parole_failure_strikes_parole_peer() {
8499        use crate::ban::{BanConfig, BanManager, ParoleState};
8500        use std::net::IpAddr;
8501
8502        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
8503        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
8504
8505        let mut mgr = BanManager::new(BanConfig {
8506            max_failures: 2,
8507            use_parole: true,
8508        });
8509
8510        let parole = ParoleState {
8511            original_contributors: [ip1].into_iter().collect(),
8512            parole_peer: Some(parole_ip),
8513        };
8514
8515        // Parole failure: strike the parole peer, not originals
8516        if let Some(pp) = parole.parole_peer {
8517            mgr.record_strike(pp);
8518        }
8519
8520        assert_eq!(*mgr.strikes_map().get(&parole_ip).unwrap(), 1);
8521        assert!(!mgr.strikes_map().contains_key(&ip1));
8522    }
8523
8524    #[tokio::test]
8525    async fn banned_peer_rejected_on_connect() {
8526        let data = vec![0xAB; 32768];
8527        let meta = make_test_torrent(&data, 16384);
8528        let storage = make_storage(&data, 16384);
8529        let config = test_config();
8530        let ban_mgr = test_ban_manager();
8531
8532        // Pre-ban an IP
8533        let banned_ip: std::net::IpAddr = "192.168.1.100".parse().unwrap();
8534        ban_mgr.write().ban(banned_ip);
8535
8536        let (atx, amask) = test_alert_channel();
8537        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8538        let handle = TorrentHandle::from_torrent(
8539            meta,
8540            irontide_core::TorrentVersion::V1Only,
8541            None,
8542            dh,
8543            dm,
8544            config,
8545            test_dht_rx(),
8546            test_dht_rx(),
8547            None,
8548            None,
8549            crate::slot_tuner::SlotTuner::disabled(4),
8550            atx,
8551            amask,
8552            None,
8553            None,
8554            Arc::clone(&ban_mgr),
8555            test_ip_filter(),
8556            Arc::new(Vec::new()),
8557            None,
8558            None,
8559            Arc::new(crate::transport::NetworkFactory::tokio()),
8560            None, // M96: hash_pool
8561            Arc::new(crate::stats::SessionCounters::new()),
8562        )
8563        .await
8564        .unwrap();
8565
8566        // Add the banned peer — it should be filtered out
8567        handle
8568            .add_peers(
8569                vec![
8570                    SocketAddr::new(banned_ip, 6881),
8571                    "10.0.0.1:6881".parse().unwrap(),
8572                ],
8573                PeerSource::Tracker,
8574            )
8575            .await
8576            .unwrap();
8577
8578        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8579        let stats = handle.stats().await.unwrap();
8580        // Only the non-banned peer should be in available pool (and may have connected)
8581        // The banned one should never appear
8582        assert!(
8583            stats.peers_available + stats.peers_connected <= 1,
8584            "banned peer should not be added: available={}, connected={}",
8585            stats.peers_available,
8586            stats.peers_connected
8587        );
8588
8589        handle.shutdown().await.unwrap();
8590    }
8591
8592    #[test]
8593    fn banned_peer_filtered_from_available() {
8594        use crate::ban::{BanConfig, BanManager};
8595        use std::net::IpAddr;
8596
8597        let banned_ip: IpAddr = "192.168.1.200".parse().unwrap();
8598        let ok_ip: IpAddr = "10.0.0.1".parse().unwrap();
8599
8600        let mgr = BanManager::new(BanConfig::default());
8601        // Not banned yet — both should pass
8602        assert!(!mgr.is_banned(&banned_ip));
8603        assert!(!mgr.is_banned(&ok_ip));
8604
8605        let mut mgr = BanManager::new(BanConfig::default());
8606        mgr.ban(banned_ip);
8607
8608        // Now banned_ip is filtered, ok_ip is not
8609        assert!(mgr.is_banned(&banned_ip));
8610        assert!(!mgr.is_banned(&ok_ip));
8611    }
8612
8613    // ---- M27: Parallel hashing tests ----
8614
8615    #[test]
8616    fn hashing_threads_config_default() {
8617        let s = crate::settings::Settings::default();
8618        let expected = {
8619            let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
8620            (cores / 4).clamp(2, 8)
8621        };
8622        assert_eq!(s.hashing_threads, expected);
8623        let tc = TorrentConfig::default();
8624        assert_eq!(tc.hashing_threads, expected);
8625    }
8626
8627    #[tokio::test]
8628    async fn checking_state_and_progress_alerts() {
8629        use crate::alert::AlertKind;
8630
8631        let data = vec![0xEEu8; 65536]; // 4 pieces of 16384
8632        let meta = make_test_torrent(&data, 16384);
8633        let storage = make_seeded_storage(&data, 16384);
8634        let config = test_config();
8635
8636        let (atx, amask) = test_alert_channel();
8637        let mut rx = atx.subscribe();
8638        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8639        let handle = TorrentHandle::from_torrent(
8640            meta,
8641            irontide_core::TorrentVersion::V1Only,
8642            None,
8643            dh,
8644            dm,
8645            config,
8646            test_dht_rx(),
8647            test_dht_rx(),
8648            None,
8649            None,
8650            crate::slot_tuner::SlotTuner::disabled(4),
8651            atx,
8652            amask,
8653            None,
8654            None,
8655            test_ban_manager(),
8656            test_ip_filter(),
8657            Arc::new(Vec::new()),
8658            None,
8659            None,
8660            Arc::new(crate::transport::NetworkFactory::tokio()),
8661            None, // M96: hash_pool
8662            Arc::new(crate::stats::SessionCounters::new()),
8663        )
8664        .await
8665        .unwrap();
8666
8667        // Collect alerts for up to 2 seconds
8668        let mut saw_checking = false;
8669        let mut progress_values: Vec<f32> = Vec::new();
8670        let mut saw_checked = false;
8671        let mut checked_have = 0u32;
8672        let mut checked_total = 0u32;
8673
8674        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
8675        while tokio::time::Instant::now() < deadline {
8676            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
8677                Ok(Ok(alert)) => match alert.kind {
8678                    AlertKind::StateChanged {
8679                        new_state: TorrentState::Checking,
8680                        ..
8681                    } => {
8682                        saw_checking = true;
8683                    }
8684                    AlertKind::CheckingProgress { progress, .. } => {
8685                        progress_values.push(progress);
8686                    }
8687                    AlertKind::TorrentChecked {
8688                        pieces_have,
8689                        pieces_total,
8690                        ..
8691                    } => {
8692                        saw_checked = true;
8693                        checked_have = pieces_have;
8694                        checked_total = pieces_total;
8695                        break;
8696                    }
8697                    _ => {}
8698                },
8699                _ => break,
8700            }
8701        }
8702
8703        assert!(saw_checking, "should have seen StateChanged → Checking");
8704        assert!(
8705            !progress_values.is_empty(),
8706            "should have seen CheckingProgress alerts"
8707        );
8708        // Progress should be monotonically increasing
8709        for w in progress_values.windows(2) {
8710            assert!(
8711                w[1] >= w[0],
8712                "progress should be monotonically increasing: {} < {}",
8713                w[0],
8714                w[1]
8715            );
8716        }
8717        assert!(saw_checked, "should have seen TorrentChecked");
8718        assert_eq!(checked_have, 4);
8719        assert_eq!(checked_total, 4);
8720
8721        // Final state should be Seeding (all pieces valid)
8722        tokio::time::sleep(Duration::from_millis(50)).await;
8723        let stats = handle.stats().await.unwrap();
8724        assert_eq!(stats.state, TorrentState::Seeding);
8725
8726        handle.shutdown().await.unwrap();
8727    }
8728
8729    #[tokio::test]
8730    #[allow(clippy::float_cmp, reason = "exact sentinel value comparison (0.0)")]
8731    async fn checking_progress_in_stats() {
8732        // When not in Checking state, checking_progress should be 0.0
8733        let data = vec![0xAB; 32768];
8734        let meta = make_test_torrent(&data, 16384);
8735        let storage = make_storage(&data, 16384);
8736        let config = test_config();
8737
8738        let (atx, amask) = test_alert_channel();
8739        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8740        let handle = TorrentHandle::from_torrent(
8741            meta,
8742            irontide_core::TorrentVersion::V1Only,
8743            None,
8744            dh,
8745            dm,
8746            config,
8747            test_dht_rx(),
8748            test_dht_rx(),
8749            None,
8750            None,
8751            crate::slot_tuner::SlotTuner::disabled(4),
8752            atx,
8753            amask,
8754            None,
8755            None,
8756            test_ban_manager(),
8757            test_ip_filter(),
8758            Arc::new(Vec::new()),
8759            None,
8760            None,
8761            Arc::new(crate::transport::NetworkFactory::tokio()),
8762            None, // M96: hash_pool
8763            Arc::new(crate::stats::SessionCounters::new()),
8764        )
8765        .await
8766        .unwrap();
8767
8768        // Give actor time to finish checking (no valid pieces → Downloading)
8769        tokio::time::sleep(Duration::from_millis(100)).await;
8770
8771        let stats = handle.stats().await.unwrap();
8772        assert_eq!(stats.state, TorrentState::Downloading);
8773        assert_eq!(
8774            stats.checking_progress, 0.0,
8775            "checking_progress should be 0.0 when not checking"
8776        );
8777
8778        handle.shutdown().await.unwrap();
8779    }
8780
8781    #[tokio::test]
8782    async fn verify_pieces_partial_data() {
8783        use crate::alert::AlertKind;
8784
8785        // 4 pieces, only first 2 have valid data
8786        let data = vec![0xCCu8; 65536]; // 4 pieces × 16384
8787        let meta = make_test_torrent(&data, 16384);
8788
8789        // Create storage and only write valid data for pieces 0 and 1
8790        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
8791        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
8792        for p in 0..2u32 {
8793            let offset = lengths.piece_offset(p) as usize;
8794            let size = lengths.piece_size(p) as usize;
8795            storage
8796                .write_chunk(p, 0, &data[offset..offset + size])
8797                .unwrap();
8798        }
8799        // Pieces 2 and 3 have no data (zeros) — won't match hash
8800
8801        let config = test_config();
8802        let (atx, amask) = test_alert_channel();
8803        let mut rx = atx.subscribe();
8804        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8805        let handle = TorrentHandle::from_torrent(
8806            meta,
8807            irontide_core::TorrentVersion::V1Only,
8808            None,
8809            dh,
8810            dm,
8811            config,
8812            test_dht_rx(),
8813            test_dht_rx(),
8814            None,
8815            None,
8816            crate::slot_tuner::SlotTuner::disabled(4),
8817            atx,
8818            amask,
8819            None,
8820            None,
8821            test_ban_manager(),
8822            test_ip_filter(),
8823            Arc::new(Vec::new()),
8824            None,
8825            None,
8826            Arc::new(crate::transport::NetworkFactory::tokio()),
8827            None, // M96: hash_pool
8828            Arc::new(crate::stats::SessionCounters::new()),
8829        )
8830        .await
8831        .unwrap();
8832
8833        // Wait for TorrentChecked alert
8834        let mut checked_have = 0u32;
8835        let mut checked_total = 0u32;
8836        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
8837        while tokio::time::Instant::now() < deadline {
8838            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
8839                Ok(Ok(alert)) => {
8840                    if let AlertKind::TorrentChecked {
8841                        pieces_have,
8842                        pieces_total,
8843                        ..
8844                    } = alert.kind
8845                    {
8846                        checked_have = pieces_have;
8847                        checked_total = pieces_total;
8848                        break;
8849                    }
8850                }
8851                _ => break,
8852            }
8853        }
8854
8855        assert_eq!(checked_have, 2, "only 2 pieces should be valid");
8856        assert_eq!(checked_total, 4);
8857
8858        // Final state should be Downloading (partial)
8859        tokio::time::sleep(Duration::from_millis(50)).await;
8860        let stats = handle.stats().await.unwrap();
8861        assert_eq!(stats.state, TorrentState::Downloading);
8862        assert_eq!(stats.pieces_have, 2);
8863        assert_eq!(stats.pieces_total, 4);
8864
8865        handle.shutdown().await.unwrap();
8866    }
8867
8868    // ---- M29: IP filter integration tests ----
8869
8870    #[tokio::test]
8871    async fn ip_filter_blocks_peers_in_handle_add_peers() {
8872        let data = vec![0xCD; 32768];
8873        let meta = make_test_torrent(&data, 16384);
8874        let storage = make_storage(&data, 16384);
8875        let config = test_config();
8876
8877        // Create an IP filter that blocks 203.0.113.0/24 (TEST-NET-3, public range)
8878        let ip_filter = {
8879            let mut f = crate::ip_filter::IpFilter::new();
8880            f.add_rule(
8881                "203.0.113.0".parse().unwrap(),
8882                "203.0.113.255".parse().unwrap(),
8883                1,
8884            );
8885            Arc::new(parking_lot::RwLock::new(f))
8886        };
8887
8888        let (atx, amask) = test_alert_channel();
8889        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8890        let handle = TorrentHandle::from_torrent(
8891            meta,
8892            irontide_core::TorrentVersion::V1Only,
8893            None,
8894            dh,
8895            dm,
8896            config,
8897            test_dht_rx(),
8898            test_dht_rx(),
8899            None,
8900            None,
8901            crate::slot_tuner::SlotTuner::disabled(4),
8902            atx,
8903            amask,
8904            None,
8905            None,
8906            test_ban_manager(),
8907            Arc::clone(&ip_filter),
8908            Arc::new(Vec::new()),
8909            None,
8910            None,
8911            Arc::new(crate::transport::NetworkFactory::tokio()),
8912            None, // M96: hash_pool
8913            Arc::new(crate::stats::SessionCounters::new()),
8914        )
8915        .await
8916        .unwrap();
8917
8918        // Add peers: one blocked (public IP in TEST-NET-3), one allowed (different public IP)
8919        let blocked_addr: SocketAddr = "203.0.113.42:6881".parse().unwrap();
8920        let allowed_addr: SocketAddr = "198.51.100.1:6881".parse().unwrap();
8921        handle
8922            .add_peers(vec![blocked_addr, allowed_addr], PeerSource::Tracker)
8923            .await
8924            .unwrap();
8925
8926        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8927        let stats = handle.stats().await.unwrap();
8928        // Only the allowed peer should be in the pool
8929        assert!(
8930            stats.peers_available + stats.peers_connected <= 1,
8931            "blocked peer should not be added: available={}, connected={}",
8932            stats.peers_available,
8933            stats.peers_connected
8934        );
8935
8936        handle.shutdown().await.unwrap();
8937    }
8938
8939    #[tokio::test]
8940    async fn set_ip_filter_replaces_filter_and_blocks_new_ip() {
8941        // Test that updating the shared IP filter takes effect for new peer additions.
8942        // Use public IPs (TEST-NET ranges) since local networks are always exempt.
8943        let data = vec![0xCD; 32768];
8944        let meta = make_test_torrent(&data, 16384);
8945        let storage = make_storage(&data, 16384);
8946        let config = test_config();
8947
8948        // Start with empty filter (everything allowed)
8949        let ip_filter: crate::session::SharedIpFilter =
8950            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
8951
8952        let (atx, amask) = test_alert_channel();
8953        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8954        let handle = TorrentHandle::from_torrent(
8955            meta,
8956            irontide_core::TorrentVersion::V1Only,
8957            None,
8958            dh,
8959            dm,
8960            config,
8961            test_dht_rx(),
8962            test_dht_rx(),
8963            None,
8964            None,
8965            crate::slot_tuner::SlotTuner::disabled(4),
8966            atx,
8967            amask,
8968            None,
8969            None,
8970            test_ban_manager(),
8971            Arc::clone(&ip_filter),
8972            Arc::new(Vec::new()),
8973            None,
8974            None,
8975            Arc::new(crate::transport::NetworkFactory::tokio()),
8976            None, // M96: hash_pool
8977            Arc::new(crate::stats::SessionCounters::new()),
8978        )
8979        .await
8980        .unwrap();
8981
8982        // Initially, peers are allowed by the IP filter.
8983        // Use a local listener so the connection succeeds and the peer stays known.
8984        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8985        let local_addr = listener.local_addr().unwrap();
8986        handle
8987            .add_peers(vec![local_addr], PeerSource::Tracker)
8988            .await
8989            .unwrap();
8990        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8991        let stats = handle.stats().await.unwrap();
8992        assert!(
8993            stats.peers_available + stats.peers_connected >= 1,
8994            "peer should be allowed initially"
8995        );
8996        handle.shutdown().await.unwrap();
8997
8998        // Now update the shared filter to block that IP range
8999        {
9000            let mut f = ip_filter.write();
9001            f.add_rule(
9002                "198.51.100.0".parse().unwrap(),
9003                "198.51.100.255".parse().unwrap(),
9004                1,
9005            );
9006        }
9007
9008        // Verify the filter is updated (public IP, so is_blocked applies)
9009        assert!(ip_filter.read().is_blocked("198.51.100.1".parse().unwrap()));
9010        // Verify a different public IP is still allowed
9011        assert!(!ip_filter.read().is_blocked("203.0.113.1".parse().unwrap()));
9012    }
9013
9014    #[test]
9015    fn relocate_files_moves_and_cleans_up() {
9016        let tmp = std::env::temp_dir().join(format!("torrent_relocate_{}", std::process::id()));
9017        let src = tmp.join("src");
9018        let dst = tmp.join("dst");
9019
9020        // Create source files mimicking multi-file torrent layout:
9021        // TorrentName/subdir/file1.txt
9022        // TorrentName/file2.txt
9023        let subdir = src.join("TorrentName").join("subdir");
9024        std::fs::create_dir_all(&subdir).unwrap();
9025        std::fs::write(subdir.join("file1.txt"), b"hello").unwrap();
9026        std::fs::write(src.join("TorrentName").join("file2.txt"), b"world").unwrap();
9027
9028        let file_paths = vec![
9029            std::path::PathBuf::from("TorrentName/subdir/file1.txt"),
9030            std::path::PathBuf::from("TorrentName/file2.txt"),
9031        ];
9032
9033        relocate_files(&src, &dst, &file_paths).unwrap();
9034
9035        // Destination should have both files
9036        assert_eq!(
9037            std::fs::read_to_string(dst.join("TorrentName/subdir/file1.txt")).unwrap(),
9038            "hello"
9039        );
9040        assert_eq!(
9041            std::fs::read_to_string(dst.join("TorrentName/file2.txt")).unwrap(),
9042            "world"
9043        );
9044
9045        // Source directory should be cleaned up (empty dirs removed)
9046        assert!(!src.join("TorrentName").join("subdir").exists());
9047        assert!(!src.join("TorrentName").exists());
9048
9049        // Cleanup
9050        let _ = std::fs::remove_dir_all(&tmp);
9051    }
9052
9053    #[test]
9054    fn relocate_files_skips_missing() {
9055        let tmp =
9056            std::env::temp_dir().join(format!("torrent_relocate_skip_{}", std::process::id()));
9057        let src = tmp.join("src");
9058        let dst = tmp.join("dst");
9059        std::fs::create_dir_all(&src).unwrap();
9060
9061        // File doesn't exist — should be skipped without error
9062        let file_paths = vec![std::path::PathBuf::from("nonexistent.txt")];
9063        relocate_files(&src, &dst, &file_paths).unwrap();
9064
9065        assert!(!dst.join("nonexistent.txt").exists());
9066
9067        let _ = std::fs::remove_dir_all(&tmp);
9068    }
9069
9070    // ---- Test: force_recheck transitions through Checking state ----
9071
9072    #[tokio::test]
9073    async fn force_recheck_transitions_to_checking() {
9074        let data = vec![0xDDu8; 32768]; // 2 pieces
9075        let meta = make_test_torrent(&data, 16384);
9076        let storage = make_seeded_storage(&data, 16384);
9077        let config = test_config();
9078
9079        let (atx, amask) = test_alert_channel();
9080        let mut arx = atx.subscribe();
9081        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9082        let handle = TorrentHandle::from_torrent(
9083            meta,
9084            irontide_core::TorrentVersion::V1Only,
9085            None,
9086            dh,
9087            dm,
9088            config,
9089            test_dht_rx(),
9090            test_dht_rx(),
9091            None,
9092            None,
9093            crate::slot_tuner::SlotTuner::disabled(4),
9094            atx,
9095            amask,
9096            None,
9097            None,
9098            test_ban_manager(),
9099            test_ip_filter(),
9100            Arc::new(Vec::new()),
9101            None,
9102            None,
9103            Arc::new(crate::transport::NetworkFactory::tokio()),
9104            None, // M96: hash_pool
9105            Arc::new(crate::stats::SessionCounters::new()),
9106        )
9107        .await
9108        .unwrap();
9109
9110        // Wait for initial verification to complete (should become Seeding)
9111        tokio::time::sleep(Duration::from_millis(100)).await;
9112        let stats = handle.stats().await.unwrap();
9113        assert_eq!(stats.state, TorrentState::Seeding, "should start as seeder");
9114
9115        // Drain any existing alerts
9116        while arx.try_recv().is_ok() {}
9117
9118        // Force recheck
9119        handle.force_recheck().await.unwrap();
9120
9121        // After force_recheck returns, look for a StateChanged alert that
9122        // went through Checking (the transition_state fires it)
9123        let mut saw_checking = false;
9124        while let Ok(alert) = arx.try_recv() {
9125            if let crate::alert::AlertKind::StateChanged { new_state, .. } = alert.kind
9126                && new_state == TorrentState::Checking
9127            {
9128                saw_checking = true;
9129            }
9130        }
9131        assert!(
9132            saw_checking,
9133            "should have transitioned through Checking state"
9134        );
9135
9136        handle.shutdown().await.unwrap();
9137    }
9138
9139    // ---- Test: force_recheck completes with correct state ----
9140
9141    #[tokio::test]
9142    async fn force_recheck_completes() {
9143        let data = vec![0xEEu8; 32768]; // 2 pieces
9144        let meta = make_test_torrent(&data, 16384);
9145        let storage = make_seeded_storage(&data, 16384);
9146        let config = test_config();
9147
9148        let (atx, amask) = test_alert_channel();
9149        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9150        let handle = TorrentHandle::from_torrent(
9151            meta,
9152            irontide_core::TorrentVersion::V1Only,
9153            None,
9154            dh,
9155            dm,
9156            config,
9157            test_dht_rx(),
9158            test_dht_rx(),
9159            None,
9160            None,
9161            crate::slot_tuner::SlotTuner::disabled(4),
9162            atx,
9163            amask,
9164            None,
9165            None,
9166            test_ban_manager(),
9167            test_ip_filter(),
9168            Arc::new(Vec::new()),
9169            None,
9170            None,
9171            Arc::new(crate::transport::NetworkFactory::tokio()),
9172            None, // M96: hash_pool
9173            Arc::new(crate::stats::SessionCounters::new()),
9174        )
9175        .await
9176        .unwrap();
9177
9178        // Wait for initial verification
9179        tokio::time::sleep(Duration::from_millis(100)).await;
9180        let stats = handle.stats().await.unwrap();
9181        assert_eq!(stats.state, TorrentState::Seeding);
9182        assert_eq!(stats.pieces_have, 2);
9183
9184        // Force recheck — should re-verify all pieces and return to Seeding
9185        handle.force_recheck().await.unwrap();
9186
9187        let stats = handle.stats().await.unwrap();
9188        assert_eq!(
9189            stats.state,
9190            TorrentState::Seeding,
9191            "should return to Seeding after recheck"
9192        );
9193        assert_eq!(stats.pieces_have, 2, "all pieces should still be verified");
9194
9195        handle.shutdown().await.unwrap();
9196    }
9197
9198    // ---- Test: rename_file succeeds with valid index ----
9199
9200    #[tokio::test]
9201    async fn rename_file_succeeds() {
9202        // Create a real file on disk that we can rename
9203        let tmp = std::env::temp_dir().join(format!("torrent_rename_{}", std::process::id()));
9204        std::fs::create_dir_all(&tmp).unwrap();
9205
9206        let data = vec![0xFFu8; 16384]; // 1 piece
9207        let meta = make_test_torrent(&data, 16384);
9208        let storage = make_seeded_storage(&data, 16384);
9209
9210        // The single-file torrent has name "test", so file path is "test"
9211        // Create the actual file on disk at download_dir/test
9212        std::fs::write(tmp.join("test"), &data).unwrap();
9213
9214        let mut config = test_config();
9215        config.download_dir = tmp.clone();
9216
9217        let (atx, amask) = test_alert_channel();
9218        let mut arx = atx.subscribe();
9219        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9220        let handle = TorrentHandle::from_torrent(
9221            meta,
9222            irontide_core::TorrentVersion::V1Only,
9223            None,
9224            dh,
9225            dm,
9226            config,
9227            test_dht_rx(),
9228            test_dht_rx(),
9229            None,
9230            None,
9231            crate::slot_tuner::SlotTuner::disabled(4),
9232            atx,
9233            amask,
9234            None,
9235            None,
9236            test_ban_manager(),
9237            test_ip_filter(),
9238            Arc::new(Vec::new()),
9239            None,
9240            None,
9241            Arc::new(crate::transport::NetworkFactory::tokio()),
9242            None, // M96: hash_pool
9243            Arc::new(crate::stats::SessionCounters::new()),
9244        )
9245        .await
9246        .unwrap();
9247
9248        // Wait for initial verification
9249        tokio::time::sleep(Duration::from_millis(100)).await;
9250
9251        // Drain existing alerts
9252        while arx.try_recv().is_ok() {}
9253
9254        // Rename file 0 to "test_renamed"
9255        handle.rename_file(0, "test_renamed".into()).await.unwrap();
9256
9257        // Check that the old file is gone and new file exists
9258        assert!(!tmp.join("test").exists(), "old file should be removed");
9259        assert!(tmp.join("test_renamed").exists(), "new file should exist");
9260
9261        // Check that FileRenamed alert was fired
9262        let mut saw_renamed = false;
9263        while let Ok(alert) = arx.try_recv() {
9264            if let AlertKind::FileRenamed { index, .. } = alert.kind {
9265                assert_eq!(index, 0);
9266                saw_renamed = true;
9267            }
9268        }
9269        assert!(saw_renamed, "should have received FileRenamed alert");
9270
9271        handle.shutdown().await.unwrap();
9272        let _ = std::fs::remove_dir_all(&tmp);
9273    }
9274
9275    // ---- Test: rename_file with invalid index returns error ----
9276
9277    #[tokio::test]
9278    async fn rename_file_invalid_index_errors() {
9279        let data = vec![0xCCu8; 16384]; // 1 piece, single-file torrent
9280        let meta = make_test_torrent(&data, 16384);
9281        let storage = make_seeded_storage(&data, 16384);
9282        let config = test_config();
9283
9284        let (atx, amask) = test_alert_channel();
9285        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9286        let handle = TorrentHandle::from_torrent(
9287            meta,
9288            irontide_core::TorrentVersion::V1Only,
9289            None,
9290            dh,
9291            dm,
9292            config,
9293            test_dht_rx(),
9294            test_dht_rx(),
9295            None,
9296            None,
9297            crate::slot_tuner::SlotTuner::disabled(4),
9298            atx,
9299            amask,
9300            None,
9301            None,
9302            test_ban_manager(),
9303            test_ip_filter(),
9304            Arc::new(Vec::new()),
9305            None,
9306            None,
9307            Arc::new(crate::transport::NetworkFactory::tokio()),
9308            None, // M96: hash_pool
9309            Arc::new(crate::stats::SessionCounters::new()),
9310        )
9311        .await
9312        .unwrap();
9313
9314        // Wait for initial verification
9315        tokio::time::sleep(Duration::from_millis(100)).await;
9316
9317        // Try to rename file index 99 (out of range)
9318        let result = handle.rename_file(99, "bad".into()).await;
9319        assert!(result.is_err(), "should fail for out-of-range file index");
9320
9321        handle.shutdown().await.unwrap();
9322    }
9323
9324    // ---- Test: FileCompleted alert fires when all pieces of a file are verified ----
9325
9326    #[tokio::test]
9327    async fn file_completed_alert_fires() {
9328        let data = vec![0xBBu8; 32768]; // 2 pieces
9329        let meta = make_test_torrent(&data, 16384);
9330        let storage = make_seeded_storage(&data, 16384);
9331        let config = test_config();
9332
9333        let (atx, amask) = test_alert_channel();
9334        let mut arx = atx.subscribe();
9335        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9336        let handle = TorrentHandle::from_torrent(
9337            meta,
9338            irontide_core::TorrentVersion::V1Only,
9339            None,
9340            dh,
9341            dm,
9342            config,
9343            test_dht_rx(),
9344            test_dht_rx(),
9345            None,
9346            None,
9347            crate::slot_tuner::SlotTuner::disabled(4),
9348            atx,
9349            amask,
9350            None,
9351            None,
9352            test_ban_manager(),
9353            test_ip_filter(),
9354            Arc::new(Vec::new()),
9355            None,
9356            None,
9357            Arc::new(crate::transport::NetworkFactory::tokio()),
9358            None, // M96: hash_pool
9359            Arc::new(crate::stats::SessionCounters::new()),
9360        )
9361        .await
9362        .unwrap();
9363
9364        // Wait for initial verification (seeded storage => all pieces verify)
9365        tokio::time::sleep(Duration::from_millis(200)).await;
9366
9367        // Should have received FileCompleted alert for the single file
9368        let mut saw_file_completed = false;
9369        while let Ok(alert) = arx.try_recv() {
9370            if let AlertKind::FileCompleted { file_index, .. } = alert.kind {
9371                assert_eq!(file_index, 0, "should be file index 0");
9372                saw_file_completed = true;
9373            }
9374        }
9375        assert!(
9376            saw_file_completed,
9377            "should have received FileCompleted alert"
9378        );
9379
9380        handle.shutdown().await.unwrap();
9381    }
9382
9383    // ---- Test: MetadataFailed alert fires (unit test on AlertKind) ----
9384
9385    #[test]
9386    fn metadata_failed_alert_fires() {
9387        // Test that MetadataFailed alert has the correct category
9388        let info_hash = Id20::from([0u8; 20]);
9389        let alert = crate::alert::Alert::new(AlertKind::MetadataFailed { info_hash });
9390        assert!(
9391            alert
9392                .category()
9393                .contains(crate::alert::AlertCategory::STATUS),
9394            "MetadataFailed should have STATUS category"
9395        );
9396        assert!(
9397            alert
9398                .category()
9399                .contains(crate::alert::AlertCategory::ERROR),
9400            "MetadataFailed should have ERROR category"
9401        );
9402
9403        // Verify it can be posted through the alert system
9404        let (tx, mut rx) = broadcast::channel(16);
9405        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
9406        post_alert(&tx, &mask, AlertKind::MetadataFailed { info_hash });
9407        let received = rx.try_recv().expect("should receive MetadataFailed alert");
9408        assert!(matches!(received.kind, AlertKind::MetadataFailed { .. }));
9409    }
9410
9411    // ---- Test: set_max_connections persists ----
9412
9413    #[tokio::test]
9414    async fn set_max_connections_persists() {
9415        let data = vec![0xAB; 32768];
9416        let meta = make_test_torrent(&data, 16384);
9417        let storage = make_storage(&data, 16384);
9418        let config = test_config();
9419
9420        let (atx, amask) = test_alert_channel();
9421        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9422        let handle = TorrentHandle::from_torrent(
9423            meta,
9424            irontide_core::TorrentVersion::V1Only,
9425            None,
9426            dh,
9427            dm,
9428            config,
9429            test_dht_rx(),
9430            test_dht_rx(),
9431            None,
9432            None,
9433            crate::slot_tuner::SlotTuner::disabled(4),
9434            atx,
9435            amask,
9436            None,
9437            None,
9438            test_ban_manager(),
9439            test_ip_filter(),
9440            Arc::new(Vec::new()),
9441            None,
9442            None,
9443            Arc::new(crate::transport::NetworkFactory::tokio()),
9444            None, // M96: hash_pool
9445            Arc::new(crate::stats::SessionCounters::new()),
9446        )
9447        .await
9448        .unwrap();
9449
9450        // Set max_connections to 10
9451        handle.set_max_connections(10).await.unwrap();
9452        let val = handle.max_connections().await.unwrap();
9453        assert_eq!(val, 10);
9454
9455        // Update to a different value
9456        handle.set_max_connections(25).await.unwrap();
9457        let val = handle.max_connections().await.unwrap();
9458        assert_eq!(val, 25);
9459
9460        // Verify stats reflect the override
9461        let stats = handle.stats().await.unwrap();
9462        assert_eq!(stats.connections_limit, 25);
9463
9464        handle.shutdown().await.unwrap();
9465    }
9466
9467    // ---- Test: max_connections default is 0 (use config.max_peers) ----
9468
9469    #[tokio::test]
9470    async fn max_connections_default() {
9471        let data = vec![0xAB; 32768];
9472        let meta = make_test_torrent(&data, 16384);
9473        let storage = make_storage(&data, 16384);
9474        let config = test_config();
9475        let expected_default = config.max_peers;
9476
9477        let (atx, amask) = test_alert_channel();
9478        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9479        let handle = TorrentHandle::from_torrent(
9480            meta,
9481            irontide_core::TorrentVersion::V1Only,
9482            None,
9483            dh,
9484            dm,
9485            config,
9486            test_dht_rx(),
9487            test_dht_rx(),
9488            None,
9489            None,
9490            crate::slot_tuner::SlotTuner::disabled(4),
9491            atx,
9492            amask,
9493            None,
9494            None,
9495            test_ban_manager(),
9496            test_ip_filter(),
9497            Arc::new(Vec::new()),
9498            None,
9499            None,
9500            Arc::new(crate::transport::NetworkFactory::tokio()),
9501            None, // M96: hash_pool
9502            Arc::new(crate::stats::SessionCounters::new()),
9503        )
9504        .await
9505        .unwrap();
9506
9507        // Default max_connections should be 0
9508        let val = handle.max_connections().await.unwrap();
9509        assert_eq!(val, 0);
9510
9511        // Stats should show config.max_peers as the effective limit
9512        let stats = handle.stats().await.unwrap();
9513        assert_eq!(stats.connections_limit, expected_default);
9514
9515        handle.shutdown().await.unwrap();
9516    }
9517
9518    // ---- Test: set_max_uploads round trip ----
9519
9520    #[tokio::test]
9521    async fn set_max_uploads_round_trip() {
9522        let data = vec![0xAB; 32768];
9523        let meta = make_test_torrent(&data, 16384);
9524        let storage = make_storage(&data, 16384);
9525        let config = test_config();
9526
9527        let (atx, amask) = test_alert_channel();
9528        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9529        let handle = TorrentHandle::from_torrent(
9530            meta,
9531            irontide_core::TorrentVersion::V1Only,
9532            None,
9533            dh,
9534            dm,
9535            config,
9536            test_dht_rx(),
9537            test_dht_rx(),
9538            None,
9539            None,
9540            crate::slot_tuner::SlotTuner::disabled(4),
9541            atx,
9542            amask,
9543            None,
9544            None,
9545            test_ban_manager(),
9546            test_ip_filter(),
9547            Arc::new(Vec::new()),
9548            None,
9549            None,
9550            Arc::new(crate::transport::NetworkFactory::tokio()),
9551            None, // M96: hash_pool
9552            Arc::new(crate::stats::SessionCounters::new()),
9553        )
9554        .await
9555        .unwrap();
9556
9557        // Set max_uploads to 8
9558        handle.set_max_uploads(8).await.unwrap();
9559        let val = handle.max_uploads().await.unwrap();
9560        assert_eq!(val, 8);
9561
9562        // Verify stats uploads_limit reflects the new value
9563        let stats = handle.stats().await.unwrap();
9564        assert_eq!(stats.uploads_limit, 8);
9565
9566        handle.shutdown().await.unwrap();
9567    }
9568
9569    // ---- Test: ExternalIpDetected alert fires ----
9570
9571    #[tokio::test]
9572    async fn external_ip_detected_alert() {
9573        let data = vec![0xAB; 32768];
9574        let meta = make_test_torrent(&data, 16384);
9575        let storage = make_storage(&data, 16384);
9576        let config = test_config();
9577
9578        let (atx, amask) = test_alert_channel();
9579        let mut arx = atx.subscribe();
9580        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9581        let handle = TorrentHandle::from_torrent(
9582            meta,
9583            irontide_core::TorrentVersion::V1Only,
9584            None,
9585            dh,
9586            dm,
9587            config,
9588            test_dht_rx(),
9589            test_dht_rx(),
9590            None,
9591            None,
9592            crate::slot_tuner::SlotTuner::disabled(4),
9593            atx,
9594            amask,
9595            None,
9596            None,
9597            test_ban_manager(),
9598            test_ip_filter(),
9599            Arc::new(Vec::new()),
9600            None,
9601            None,
9602            Arc::new(crate::transport::NetworkFactory::tokio()),
9603            None, // M96: hash_pool
9604            Arc::new(crate::stats::SessionCounters::new()),
9605        )
9606        .await
9607        .unwrap();
9608
9609        // Drain any initial alerts
9610        while arx.try_recv().is_ok() {}
9611
9612        // Send UpdateExternalIp command
9613        let test_ip: std::net::IpAddr = "203.0.113.42".parse().unwrap();
9614        handle
9615            .cmd_tx
9616            .send(TorrentCommand::UpdateExternalIp { ip: test_ip })
9617            .await
9618            .unwrap();
9619
9620        // Wait for the actor to process
9621        tokio::time::sleep(Duration::from_millis(50)).await;
9622
9623        // Check for ExternalIpDetected alert
9624        let mut saw_alert = false;
9625        while let Ok(alert) = arx.try_recv() {
9626            if let AlertKind::ExternalIpDetected { ip } = alert.kind {
9627                assert_eq!(ip, test_ip);
9628                saw_alert = true;
9629            }
9630        }
9631        assert!(saw_alert, "should have received ExternalIpDetected alert");
9632
9633        handle.shutdown().await.unwrap();
9634    }
9635
9636    // ---- Test: get_peer_info returns connected peers ----
9637
9638    #[tokio::test]
9639    async fn get_peer_info_returns_connected_peers() {
9640        let data = vec![0xAB; 65536]; // 64 KiB
9641        let meta = make_test_torrent(&data, 16384); // 4 pieces
9642        let storage = make_storage(&data, 16384);
9643        let config = test_config();
9644
9645        let (atx, amask) = test_alert_channel();
9646        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9647        let handle = TorrentHandle::from_torrent(
9648            meta.clone(),
9649            irontide_core::TorrentVersion::V1Only,
9650            None,
9651            dh,
9652            dm,
9653            config,
9654            test_dht_rx(),
9655            test_dht_rx(),
9656            None,
9657            None,
9658            crate::slot_tuner::SlotTuner::disabled(4),
9659            atx,
9660            amask,
9661            None,
9662            None,
9663            test_ban_manager(),
9664            test_ip_filter(),
9665            Arc::new(Vec::new()),
9666            None,
9667            None,
9668            Arc::new(crate::transport::NetworkFactory::tokio()),
9669            None, // M96: hash_pool
9670            Arc::new(crate::stats::SessionCounters::new()),
9671        )
9672        .await
9673        .unwrap();
9674
9675        // Set up a fake peer via TCP handshake
9676        let stats = handle.stats().await.unwrap();
9677        let listen_port = stats.peers_connected; // Initially 0
9678
9679        // Add a peer to the available pool and let the actor connect
9680        let peer_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9681        let peer_addr = peer_listener.local_addr().unwrap();
9682
9683        handle
9684            .add_peers(vec![peer_addr], PeerSource::Tracker)
9685            .await
9686            .unwrap();
9687
9688        // Accept the connection and complete the handshake
9689        let accept_timeout =
9690            tokio::time::timeout(Duration::from_secs(2), peer_listener.accept()).await;
9691        if let Ok(Ok((mut stream, _))) = accept_timeout {
9692            // Read handshake
9693            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
9694            if tokio::time::timeout(Duration::from_millis(500), stream.read_exact(&mut hs_buf))
9695                .await
9696                .is_ok()
9697            {
9698                // Send back handshake
9699                let hs = Handshake::new(meta.info_hash, Id20::from([0xBB; 20]));
9700                let hs_bytes = hs.to_bytes();
9701                let _ = stream.write_all(&hs_bytes).await;
9702
9703                // Give the actor time to register the peer
9704                tokio::time::sleep(Duration::from_millis(200)).await;
9705
9706                // Now query peer info
9707                let peer_info = handle.get_peer_info().await.unwrap();
9708                // We should have at least one peer (the one we just handshaked)
9709                if !peer_info.is_empty() {
9710                    let p = &peer_info[0];
9711                    // Verify default choking/interested state
9712                    assert!(p.peer_choking, "peer should be choking us initially");
9713                    // M107: we unconditionally unchoke on connect, so am_choking starts false
9714                    assert!(
9715                        !p.am_choking,
9716                        "we should not be choking peer after connect (M107 unconditional unchoke)"
9717                    );
9718                    assert!(
9719                        !p.peer_interested,
9720                        "peer should not be interested initially"
9721                    );
9722                    assert_eq!(p.num_pieces, 0);
9723                    assert_eq!(p.source, PeerSource::Tracker);
9724                }
9725            }
9726        }
9727        // Even if handshake timing fails, at least verify the API works
9728        let _ = handle.get_peer_info().await.unwrap();
9729        assert_eq!(listen_port, 0); // sanity: initially had no peers
9730
9731        handle.shutdown().await.unwrap();
9732    }
9733
9734    // ---- Test: get_peer_info empty when no peers ----
9735
9736    #[tokio::test]
9737    async fn get_peer_info_empty_when_no_peers() {
9738        let data = vec![0xAB; 32768];
9739        let meta = make_test_torrent(&data, 16384);
9740        let storage = make_storage(&data, 16384);
9741        let config = test_config();
9742
9743        let (atx, amask) = test_alert_channel();
9744        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9745        let handle = TorrentHandle::from_torrent(
9746            meta,
9747            irontide_core::TorrentVersion::V1Only,
9748            None,
9749            dh,
9750            dm,
9751            config,
9752            test_dht_rx(),
9753            test_dht_rx(),
9754            None,
9755            None,
9756            crate::slot_tuner::SlotTuner::disabled(4),
9757            atx,
9758            amask,
9759            None,
9760            None,
9761            test_ban_manager(),
9762            test_ip_filter(),
9763            Arc::new(Vec::new()),
9764            None,
9765            None,
9766            Arc::new(crate::transport::NetworkFactory::tokio()),
9767            None, // M96: hash_pool
9768            Arc::new(crate::stats::SessionCounters::new()),
9769        )
9770        .await
9771        .unwrap();
9772
9773        let peer_info = handle.get_peer_info().await.unwrap();
9774        assert!(peer_info.is_empty(), "should have no peers initially");
9775
9776        handle.shutdown().await.unwrap();
9777    }
9778
9779    // ---- Test: get_download_queue empty initially ----
9780
9781    #[tokio::test]
9782    async fn get_download_queue_empty_initially() {
9783        let data = vec![0xAB; 32768];
9784        let meta = make_test_torrent(&data, 16384);
9785        let storage = make_storage(&data, 16384);
9786        let config = test_config();
9787
9788        let (atx, amask) = test_alert_channel();
9789        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9790        let handle = TorrentHandle::from_torrent(
9791            meta,
9792            irontide_core::TorrentVersion::V1Only,
9793            None,
9794            dh,
9795            dm,
9796            config,
9797            test_dht_rx(),
9798            test_dht_rx(),
9799            None,
9800            None,
9801            crate::slot_tuner::SlotTuner::disabled(4),
9802            atx,
9803            amask,
9804            None,
9805            None,
9806            test_ban_manager(),
9807            test_ip_filter(),
9808            Arc::new(Vec::new()),
9809            None,
9810            None,
9811            Arc::new(crate::transport::NetworkFactory::tokio()),
9812            None, // M96: hash_pool
9813            Arc::new(crate::stats::SessionCounters::new()),
9814        )
9815        .await
9816        .unwrap();
9817
9818        let queue = handle.get_download_queue().await.unwrap();
9819        assert!(
9820            queue.is_empty(),
9821            "download queue should be empty with no active downloads"
9822        );
9823
9824        handle.shutdown().await.unwrap();
9825    }
9826
9827    // ---- Test: have_piece false initially ----
9828
9829    #[tokio::test]
9830    async fn have_piece_false_initially() {
9831        let data = vec![0xAB; 32768]; // 32 KiB = 2 pieces
9832        let meta = make_test_torrent(&data, 16384);
9833        let storage = make_storage(&data, 16384);
9834        let config = test_config();
9835
9836        let (atx, amask) = test_alert_channel();
9837        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9838        let handle = TorrentHandle::from_torrent(
9839            meta,
9840            irontide_core::TorrentVersion::V1Only,
9841            None,
9842            dh,
9843            dm,
9844            config,
9845            test_dht_rx(),
9846            test_dht_rx(),
9847            None,
9848            None,
9849            crate::slot_tuner::SlotTuner::disabled(4),
9850            atx,
9851            amask,
9852            None,
9853            None,
9854            test_ban_manager(),
9855            test_ip_filter(),
9856            Arc::new(Vec::new()),
9857            None,
9858            None,
9859            Arc::new(crate::transport::NetworkFactory::tokio()),
9860            None, // M96: hash_pool
9861            Arc::new(crate::stats::SessionCounters::new()),
9862        )
9863        .await
9864        .unwrap();
9865
9866        assert!(
9867            !handle.have_piece(0).await.unwrap(),
9868            "piece 0 should not be downloaded initially"
9869        );
9870        assert!(
9871            !handle.have_piece(1).await.unwrap(),
9872            "piece 1 should not be downloaded initially"
9873        );
9874
9875        handle.shutdown().await.unwrap();
9876    }
9877
9878    // ---- Test: piece_availability empty with no peers ----
9879
9880    #[tokio::test]
9881    async fn piece_availability_empty_no_peers() {
9882        let data = vec![0xAB; 32768]; // 2 pieces
9883        let meta = make_test_torrent(&data, 16384);
9884        let storage = make_storage(&data, 16384);
9885        let config = test_config();
9886
9887        let (atx, amask) = test_alert_channel();
9888        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9889        let handle = TorrentHandle::from_torrent(
9890            meta,
9891            irontide_core::TorrentVersion::V1Only,
9892            None,
9893            dh,
9894            dm,
9895            config,
9896            test_dht_rx(),
9897            test_dht_rx(),
9898            None,
9899            None,
9900            crate::slot_tuner::SlotTuner::disabled(4),
9901            atx,
9902            amask,
9903            None,
9904            None,
9905            test_ban_manager(),
9906            test_ip_filter(),
9907            Arc::new(Vec::new()),
9908            None,
9909            None,
9910            Arc::new(crate::transport::NetworkFactory::tokio()),
9911            None, // M96: hash_pool
9912            Arc::new(crate::stats::SessionCounters::new()),
9913        )
9914        .await
9915        .unwrap();
9916
9917        let avail = handle.piece_availability().await.unwrap();
9918        assert_eq!(avail.len(), 2, "should have availability for 2 pieces");
9919        assert!(
9920            avail.iter().all(|&c| c == 0),
9921            "all availability counts should be 0 with no peers"
9922        );
9923
9924        handle.shutdown().await.unwrap();
9925    }
9926
9927    // ---- Test: file_progress zeros initially ----
9928
9929    #[tokio::test]
9930    async fn file_progress_zeros_initially() {
9931        let data = vec![0xAB; 32768]; // single-file, 2 pieces
9932        let meta = make_test_torrent(&data, 16384);
9933        let storage = make_storage(&data, 16384);
9934        let config = test_config();
9935
9936        let (atx, amask) = test_alert_channel();
9937        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9938        let handle = TorrentHandle::from_torrent(
9939            meta,
9940            irontide_core::TorrentVersion::V1Only,
9941            None,
9942            dh,
9943            dm,
9944            config,
9945            test_dht_rx(),
9946            test_dht_rx(),
9947            None,
9948            None,
9949            crate::slot_tuner::SlotTuner::disabled(4),
9950            atx,
9951            amask,
9952            None,
9953            None,
9954            test_ban_manager(),
9955            test_ip_filter(),
9956            Arc::new(Vec::new()),
9957            None,
9958            None,
9959            Arc::new(crate::transport::NetworkFactory::tokio()),
9960            None, // M96: hash_pool
9961            Arc::new(crate::stats::SessionCounters::new()),
9962        )
9963        .await
9964        .unwrap();
9965
9966        let progress = handle.file_progress().await.unwrap();
9967        assert_eq!(progress.len(), 1, "single-file torrent should have 1 entry");
9968        assert_eq!(progress[0], 0, "no bytes should be downloaded initially");
9969
9970        handle.shutdown().await.unwrap();
9971    }
9972
9973    // ---- Test: file_progress length matches file count (multi-file) ----
9974
9975    /// Build a multi-file `TorrentMetaV1` from a total data blob and file lengths.
9976    fn make_test_torrent_multi(
9977        data: &[u8],
9978        piece_length: u64,
9979        file_lengths: &[u64],
9980    ) -> TorrentMetaV1 {
9981        use serde::Serialize;
9982
9983        #[derive(Serialize)]
9984        struct FileE {
9985            length: u64,
9986            path: Vec<String>,
9987        }
9988
9989        #[derive(Serialize)]
9990        struct Info<'a> {
9991            name: &'a str,
9992            #[serde(rename = "piece length")]
9993            piece_length: u64,
9994            #[serde(with = "serde_bytes")]
9995            pieces: &'a [u8],
9996            files: Vec<FileE>,
9997        }
9998
9999        #[derive(Serialize)]
10000        struct Torrent<'a> {
10001            info: Info<'a>,
10002        }
10003
10004        let mut pieces = Vec::new();
10005        let mut offset = 0;
10006        while offset < data.len() {
10007            let end = (offset + piece_length as usize).min(data.len());
10008            let hash = irontide_core::sha1(&data[offset..end]);
10009            pieces.extend_from_slice(hash.as_bytes());
10010            offset = end;
10011        }
10012
10013        let files: Vec<FileE> = file_lengths
10014            .iter()
10015            .enumerate()
10016            .map(|(i, &len)| FileE {
10017                length: len,
10018                path: vec![format!("file{i}.bin")],
10019            })
10020            .collect();
10021
10022        let t = Torrent {
10023            info: Info {
10024                name: "test_multi",
10025                piece_length,
10026                pieces: &pieces,
10027                files,
10028            },
10029        };
10030
10031        let bytes = irontide_bencode::to_bytes(&t).unwrap();
10032        torrent_from_bytes(&bytes).unwrap()
10033    }
10034
10035    #[tokio::test]
10036    async fn file_progress_length_matches_file_count() {
10037        // 3 files: 10000 + 20000 + 2768 = 32768 bytes total, 2 pieces of 16384
10038        let data = vec![0xCD; 32768];
10039        let file_lengths = [10000u64, 20000, 2768];
10040        let meta = make_test_torrent_multi(&data, 16384, &file_lengths);
10041        let storage = make_storage(&data, 16384);
10042        let config = test_config();
10043
10044        let (atx, amask) = test_alert_channel();
10045        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10046        let handle = TorrentHandle::from_torrent(
10047            meta,
10048            irontide_core::TorrentVersion::V1Only,
10049            None,
10050            dh,
10051            dm,
10052            config,
10053            test_dht_rx(),
10054            test_dht_rx(),
10055            None,
10056            None,
10057            crate::slot_tuner::SlotTuner::disabled(4),
10058            atx,
10059            amask,
10060            None,
10061            None,
10062            test_ban_manager(),
10063            test_ip_filter(),
10064            Arc::new(Vec::new()),
10065            None,
10066            None,
10067            Arc::new(crate::transport::NetworkFactory::tokio()),
10068            None, // M96: hash_pool
10069            Arc::new(crate::stats::SessionCounters::new()),
10070        )
10071        .await
10072        .unwrap();
10073
10074        let progress = handle.file_progress().await.unwrap();
10075        assert_eq!(
10076            progress.len(),
10077            3,
10078            "multi-file torrent should have 3 entries"
10079        );
10080        assert!(
10081            progress.iter().all(|&b| b == 0),
10082            "all progress should be 0 initially"
10083        );
10084
10085        handle.shutdown().await.unwrap();
10086    }
10087
10088    // ---- Test: is_valid returns true for active torrent ----
10089
10090    #[tokio::test]
10091    async fn is_valid_true_for_active() {
10092        let data = vec![0xAB; 32768];
10093        let meta = make_test_torrent(&data, 16384);
10094        let storage = make_storage(&data, 16384);
10095        let config = test_config();
10096
10097        let (atx, amask) = test_alert_channel();
10098        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10099        let handle = TorrentHandle::from_torrent(
10100            meta,
10101            irontide_core::TorrentVersion::V1Only,
10102            None,
10103            dh,
10104            dm,
10105            config,
10106            test_dht_rx(),
10107            test_dht_rx(),
10108            None,
10109            None,
10110            crate::slot_tuner::SlotTuner::disabled(4),
10111            atx,
10112            amask,
10113            None,
10114            None,
10115            test_ban_manager(),
10116            test_ip_filter(),
10117            Arc::new(Vec::new()),
10118            None,
10119            None,
10120            Arc::new(crate::transport::NetworkFactory::tokio()),
10121            None, // M96: hash_pool
10122            Arc::new(crate::stats::SessionCounters::new()),
10123        )
10124        .await
10125        .unwrap();
10126
10127        assert!(
10128            handle.is_valid(),
10129            "handle should be valid while torrent actor is alive"
10130        );
10131
10132        handle.shutdown().await.unwrap();
10133    }
10134
10135    // ---- Test: is_valid returns false after shutdown ----
10136
10137    #[tokio::test]
10138    async fn is_valid_false_after_remove() {
10139        let data = vec![0xAB; 32768];
10140        let meta = make_test_torrent(&data, 16384);
10141        let storage = make_storage(&data, 16384);
10142        let config = test_config();
10143
10144        let (atx, amask) = test_alert_channel();
10145        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10146        let handle = TorrentHandle::from_torrent(
10147            meta,
10148            irontide_core::TorrentVersion::V1Only,
10149            None,
10150            dh,
10151            dm,
10152            config,
10153            test_dht_rx(),
10154            test_dht_rx(),
10155            None,
10156            None,
10157            crate::slot_tuner::SlotTuner::disabled(4),
10158            atx,
10159            amask,
10160            None,
10161            None,
10162            test_ban_manager(),
10163            test_ip_filter(),
10164            Arc::new(Vec::new()),
10165            None,
10166            None,
10167            Arc::new(crate::transport::NetworkFactory::tokio()),
10168            None, // M96: hash_pool
10169            Arc::new(crate::stats::SessionCounters::new()),
10170        )
10171        .await
10172        .unwrap();
10173
10174        assert!(handle.is_valid());
10175
10176        // Shutdown the torrent (simulating removal)
10177        handle.shutdown().await.unwrap();
10178
10179        // Give the actor time to stop and close the channel
10180        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10181
10182        assert!(
10183            !handle.is_valid(),
10184            "handle should be invalid after shutdown"
10185        );
10186    }
10187
10188    // ---- Test: clear_error resets error state ----
10189
10190    #[tokio::test]
10191    async fn clear_error_resets() {
10192        let data = vec![0xAB; 32768];
10193        let meta = make_test_torrent(&data, 16384);
10194        let storage = make_storage(&data, 16384);
10195        let config = test_config();
10196
10197        let (atx, amask) = test_alert_channel();
10198        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10199        let handle = TorrentHandle::from_torrent(
10200            meta,
10201            irontide_core::TorrentVersion::V1Only,
10202            None,
10203            dh,
10204            dm,
10205            config,
10206            test_dht_rx(),
10207            test_dht_rx(),
10208            None,
10209            None,
10210            crate::slot_tuner::SlotTuner::disabled(4),
10211            atx,
10212            amask,
10213            None,
10214            None,
10215            test_ban_manager(),
10216            test_ip_filter(),
10217            Arc::new(Vec::new()),
10218            None,
10219            None,
10220            Arc::new(crate::transport::NetworkFactory::tokio()),
10221            None, // M96: hash_pool
10222            Arc::new(crate::stats::SessionCounters::new()),
10223        )
10224        .await
10225        .unwrap();
10226
10227        // Initially no error
10228        let stats = handle.stats().await.unwrap();
10229        assert!(stats.error.is_empty());
10230        assert_eq!(stats.error_file, -1);
10231
10232        // Clear error (no-op when no error) should succeed without issue
10233        handle.clear_error().await.unwrap();
10234
10235        let stats = handle.stats().await.unwrap();
10236        assert!(stats.error.is_empty());
10237        assert_eq!(stats.error_file, -1);
10238
10239        handle.shutdown().await.unwrap();
10240    }
10241
10242    // ---- Test: flags round trip ----
10243
10244    #[tokio::test]
10245    async fn flags_round_trip() {
10246        let data = vec![0xAB; 32768];
10247        let meta = make_test_torrent(&data, 16384);
10248        let storage = make_storage(&data, 16384);
10249        let config = test_config();
10250
10251        let (atx, amask) = test_alert_channel();
10252        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10253        let handle = TorrentHandle::from_torrent(
10254            meta,
10255            irontide_core::TorrentVersion::V1Only,
10256            None,
10257            dh,
10258            dm,
10259            config,
10260            test_dht_rx(),
10261            test_dht_rx(),
10262            None,
10263            None,
10264            crate::slot_tuner::SlotTuner::disabled(4),
10265            atx,
10266            amask,
10267            None,
10268            None,
10269            test_ban_manager(),
10270            test_ip_filter(),
10271            Arc::new(Vec::new()),
10272            None,
10273            None,
10274            Arc::new(crate::transport::NetworkFactory::tokio()),
10275            None, // M96: hash_pool
10276            Arc::new(crate::stats::SessionCounters::new()),
10277        )
10278        .await
10279        .unwrap();
10280
10281        // Initial flags: torrent starts downloading (not paused), no sequential, no super seeding
10282        let initial = handle.flags().await.unwrap();
10283        assert!(!initial.contains(crate::types::TorrentFlags::PAUSED));
10284        assert!(!initial.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10285        assert!(!initial.contains(crate::types::TorrentFlags::SUPER_SEEDING));
10286
10287        // Enable sequential download via set_flags
10288        handle
10289            .set_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10290            .await
10291            .unwrap();
10292        let after_set = handle.flags().await.unwrap();
10293        assert!(after_set.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10294
10295        // Disable it via unset_flags
10296        handle
10297            .unset_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10298            .await
10299            .unwrap();
10300        let after_unset = handle.flags().await.unwrap();
10301        assert!(!after_unset.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10302
10303        // Verify sequential_download state via the dedicated query
10304        assert!(!handle.is_sequential_download().await.unwrap());
10305
10306        handle.shutdown().await.unwrap();
10307    }
10308
10309    // ---- Test: connect_peer does not error ----
10310
10311    #[tokio::test]
10312    async fn connect_peer_no_error() {
10313        let data = vec![0xAB; 32768];
10314        let meta = make_test_torrent(&data, 16384);
10315        let storage = make_storage(&data, 16384);
10316        let config = test_config();
10317
10318        let (atx, amask) = test_alert_channel();
10319        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10320        let handle = TorrentHandle::from_torrent(
10321            meta,
10322            irontide_core::TorrentVersion::V1Only,
10323            None,
10324            dh,
10325            dm,
10326            config,
10327            test_dht_rx(),
10328            test_dht_rx(),
10329            None,
10330            None,
10331            crate::slot_tuner::SlotTuner::disabled(4),
10332            atx,
10333            amask,
10334            None,
10335            None,
10336            test_ban_manager(),
10337            test_ip_filter(),
10338            Arc::new(Vec::new()),
10339            None,
10340            None,
10341            Arc::new(crate::transport::NetworkFactory::tokio()),
10342            None, // M96: hash_pool
10343            Arc::new(crate::stats::SessionCounters::new()),
10344        )
10345        .await
10346        .unwrap();
10347
10348        // connect_peer should not error even though the peer doesn't exist
10349        // (the connection attempt will fail asynchronously, but the command itself succeeds)
10350        let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap();
10351        handle.connect_peer(addr).await.unwrap();
10352
10353        // Give the actor a moment to process
10354        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
10355
10356        handle.shutdown().await.unwrap();
10357    }
10358
10359    // ---- BEP 52 hash serving tests (M87) ----
10360
10361    /// Build a minimal `TorrentMetaV2` with piece-layer hashes for testing.
10362    fn make_test_meta_v2(
10363        piece_hashes: &[irontide_core::Id32],
10364        file_root: irontide_core::Id32,
10365        piece_length: u64,
10366        file_length: u64,
10367    ) -> irontide_core::TorrentMetaV2 {
10368        use std::collections::BTreeMap;
10369
10370        // Concatenate piece hashes into raw bytes
10371        let mut layer_bytes = Vec::with_capacity(piece_hashes.len() * 32);
10372        for h in piece_hashes {
10373            layer_bytes.extend_from_slice(&h.0);
10374        }
10375
10376        let mut piece_layers = BTreeMap::new();
10377        piece_layers.insert(file_root, layer_bytes);
10378
10379        let file_tree = irontide_core::FileTreeNode::Directory({
10380            let mut children = BTreeMap::new();
10381            children.insert(
10382                "test.dat".to_string(),
10383                irontide_core::FileTreeNode::File(irontide_core::V2FileAttr {
10384                    length: file_length,
10385                    pieces_root: Some(file_root),
10386                }),
10387            );
10388            children
10389        });
10390
10391        irontide_core::TorrentMetaV2 {
10392            info_hashes: irontide_core::InfoHashes::v2_only(irontide_core::Id32::ZERO),
10393            info_bytes: None,
10394            announce: None,
10395            announce_list: None,
10396            comment: None,
10397            created_by: None,
10398            creation_date: None,
10399            info: irontide_core::InfoDictV2 {
10400                name: "test".to_string(),
10401                piece_length,
10402                meta_version: 2,
10403                file_tree,
10404                ssl_cert: None,
10405            },
10406            piece_layers,
10407            ssl_cert: None,
10408        }
10409    }
10410
10411    #[test]
10412    fn test_serve_hashes_v2_piece_layer() {
10413        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
10414        // => blocks_per_piece = 1, piece_layer_base = 0
10415        let hashes: Vec<irontide_core::Id32> = (0..4u8)
10416            .map(|i| {
10417                let mut h = [0u8; 32];
10418                h[0] = i;
10419                irontide_core::Id32(h)
10420            })
10421            .collect();
10422        let file_root = irontide_core::Id32([0xAA; 32]);
10423        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
10424        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
10425
10426        let request = irontide_core::HashRequest {
10427            file_root,
10428            base: 0, // piece layer when blocks_per_piece = 1
10429            index: 0,
10430            count: 4,
10431            proof_layers: 0,
10432        };
10433
10434        let result = serve_hashes(
10435            Some(&meta),
10436            irontide_core::TorrentVersion::V2Only,
10437            Some(&lengths),
10438            &request,
10439        );
10440        let served = result.expect("should serve hashes");
10441        assert_eq!(served.len(), 4);
10442        for (i, h) in served.iter().enumerate() {
10443            assert_eq!(h.0[0], i as u8);
10444        }
10445    }
10446
10447    #[test]
10448    fn test_serve_hashes_rejects_v1_only() {
10449        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
10450        let file_root = irontide_core::Id32([0xAA; 32]);
10451        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
10452        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
10453
10454        let request = irontide_core::HashRequest {
10455            file_root,
10456            base: 0,
10457            index: 0,
10458            count: 1,
10459            proof_layers: 0,
10460        };
10461
10462        let result = serve_hashes(
10463            Some(&meta),
10464            irontide_core::TorrentVersion::V1Only,
10465            Some(&lengths),
10466            &request,
10467        );
10468        assert!(result.is_none(), "V1Only should reject hash requests");
10469    }
10470
10471    #[test]
10472    fn test_serve_hashes_rejects_unknown_root() {
10473        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
10474        let file_root = irontide_core::Id32([0xAA; 32]);
10475        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
10476        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
10477
10478        // Request a different file root that doesn't exist
10479        let unknown_root = irontide_core::Id32([0xFF; 32]);
10480        let request = irontide_core::HashRequest {
10481            file_root: unknown_root,
10482            base: 0,
10483            index: 0,
10484            count: 1,
10485            proof_layers: 0,
10486        };
10487
10488        let result = serve_hashes(
10489            Some(&meta),
10490            irontide_core::TorrentVersion::V2Only,
10491            Some(&lengths),
10492            &request,
10493        );
10494        assert!(result.is_none(), "unknown file_root should reject");
10495    }
10496
10497    #[test]
10498    fn test_serve_hashes_rejects_out_of_bounds() {
10499        // 2 piece hashes, piece_length = 16384, chunk_size = 16384
10500        let hashes: Vec<irontide_core::Id32> =
10501            (0..2u8).map(|i| irontide_core::Id32([i; 32])).collect();
10502        let file_root = irontide_core::Id32([0xAA; 32]);
10503        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 2);
10504        let lengths = Lengths::new(16384 * 2, 16384, DEFAULT_CHUNK_SIZE);
10505
10506        // Request starting at index 5, which is beyond the 2 available hashes
10507        let request = irontide_core::HashRequest {
10508            file_root,
10509            base: 0,
10510            index: 5,
10511            count: 1,
10512            proof_layers: 0,
10513        };
10514
10515        let result = serve_hashes(
10516            Some(&meta),
10517            irontide_core::TorrentVersion::V2Only,
10518            Some(&lengths),
10519            &request,
10520        );
10521        assert!(result.is_none(), "out-of-bounds index should reject");
10522    }
10523
10524    #[test]
10525    fn test_serve_hashes_includes_proofs() {
10526        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
10527        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
10528        let hashes: Vec<irontide_core::Id32> =
10529            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
10530        let file_root = irontide_core::Id32([0xAA; 32]);
10531        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
10532        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
10533
10534        // Request 1 hash with 1 proof layer
10535        let request = irontide_core::HashRequest {
10536            file_root,
10537            base: 0,
10538            index: 0,
10539            count: 1,
10540            proof_layers: 1,
10541        };
10542
10543        let result = serve_hashes(
10544            Some(&meta),
10545            irontide_core::TorrentVersion::V2Only,
10546            Some(&lengths),
10547            &request,
10548        );
10549        let served = result.expect("should serve hashes with proofs");
10550        // 1 requested hash + 1 proof hash (sibling of leaf 0) = 2 total
10551        assert_eq!(served.len(), 2, "should have 1 data hash + 1 proof hash");
10552        // First hash is the requested piece hash
10553        assert_eq!(served[0], hashes[0]);
10554        // Second hash is the sibling (proof) — which is hashes[1]
10555        assert_eq!(served[1], hashes[1]);
10556    }
10557
10558    #[test]
10559    fn test_serve_hashes_proof_with_batch() {
10560        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
10561        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
10562        //
10563        // Tree layout (1-indexed heap):
10564        //          [1] root
10565        //        /          \
10566        //     [2]            [3]
10567        //    /    \         /    \
10568        //  [4]h0  [5]h1  [6]h2  [7]h3
10569        //
10570        // Request count=2 at index=0 => subtree rooted at [2] (h0, h1).
10571        // subtree_depth = log2(2) = 1, so we skip 1 level of the proof path.
10572        // proof_path(0) = [h1, hash(h2,h3)] — h1 is internal to subtree,
10573        // hash(h2,h3) is the uncle above. We skip h1 and send hash(h2,h3).
10574        let hashes: Vec<irontide_core::Id32> =
10575            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
10576        let file_root = irontide_core::Id32([0xAA; 32]);
10577        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
10578        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
10579
10580        let request = irontide_core::HashRequest {
10581            file_root,
10582            base: 0,
10583            index: 0,
10584            count: 2,
10585            proof_layers: 1,
10586        };
10587
10588        let result = serve_hashes(
10589            Some(&meta),
10590            irontide_core::TorrentVersion::V2Only,
10591            Some(&lengths),
10592            &request,
10593        );
10594        let served = result.expect("should serve hashes with batch proof");
10595        // 2 base hashes + 1 uncle hash = 3 total
10596        assert_eq!(served.len(), 3, "should have 2 data hashes + 1 uncle hash");
10597        // First two are the requested piece hashes
10598        assert_eq!(served[0], hashes[0]);
10599        assert_eq!(served[1], hashes[1]);
10600        // Third is the uncle: sibling of the subtree root at [2],
10601        // which is the node at [3] = hash(h2, h3)
10602        let tree = irontide_core::MerkleTree::from_leaves(&hashes);
10603        let expected_uncle = tree.layer(1)[1]; // layer 1 has 2 nodes; index 1 is the right one
10604        assert_eq!(served[2], expected_uncle);
10605
10606        // Verify the proof is valid: reconstruct subtree root from base hashes,
10607        // then verify against the tree root using the uncle hash
10608        let sub_root = irontide_core::MerkleTree::root_from_hashes(&served[..2]);
10609        let uncle_hashes = &served[2..];
10610        let leaf_index = request.index as usize / 2; // 0 / 2 = 0
10611        assert!(
10612            irontide_core::MerkleTree::verify_proof(
10613                tree.root(),
10614                sub_root,
10615                leaf_index,
10616                uncle_hashes
10617            ),
10618            "subtree proof should verify against tree root"
10619        );
10620    }
10621
10622    #[test]
10623    fn is_i2p_synthetic_addr_detects_240_range() {
10624        assert!(is_i2p_synthetic_addr(&"240.0.0.1:1".parse().unwrap()));
10625        assert!(is_i2p_synthetic_addr(
10626            &"255.255.255.255:65535".parse().unwrap()
10627        ));
10628        assert!(!is_i2p_synthetic_addr(&"192.168.1.1:6881".parse().unwrap()));
10629        assert!(!is_i2p_synthetic_addr(&"[::1]:6881".parse().unwrap()));
10630    }
10631
10632    #[test]
10633    fn v6_retry_delay_progression() {
10634        // Verify exponential backoff: 100, 200, 400, 800, 1600, 3200, 5000, 5000...
10635        let expected_ms = [100, 200, 400, 800, 1600, 3200, 5000, 5000, 5000, 5000, 5000];
10636        for (count, &expected) in expected_ms.iter().enumerate() {
10637            let delay_ms = {
10638                let base_ms: u64 = 100;
10639                let max_ms: u64 = 5000;
10640                base_ms
10641                    .saturating_mul(1u64.checked_shl(count as u32).unwrap_or(u64::MAX))
10642                    .min(max_ms)
10643            };
10644            assert_eq!(
10645                delay_ms, expected,
10646                "count={count}: expected {expected}ms, got {delay_ms}ms"
10647            );
10648        }
10649    }
10650
10651    // ---- M104: Per-peer backoff and max_in_flight formula tests ----
10652
10653    #[test]
10654    fn peer_backoff_exponential() {
10655        // Verify the M104 backoff formula: 200ms * 2^attempt, capped at 30s.
10656        // attempt starts at 1 (first failure increments 0 → 1).
10657        let expected_ms: Vec<u64> = vec![400, 800, 1600, 3200, 6400, 12800, 25600, 30000, 30000];
10658        for (i, &expected) in expected_ms.iter().enumerate() {
10659            let attempt = (i as u32) + 1; // attempt counts start at 1
10660            let delay_ms = 200u64.saturating_mul(1u64 << attempt.min(10)).min(30_000);
10661            assert_eq!(
10662                delay_ms, expected,
10663                "attempt={attempt}: expected {expected}ms, got {delay_ms}ms"
10664            );
10665        }
10666    }
10667
10668    #[test]
10669    fn peer_backoff_clears_on_data() {
10670        // Verify that backoff map operations work correctly:
10671        // insert on disconnect, remove on data received.
10672        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
10673        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
10674
10675        // No backoff initially
10676        assert!(!backoff.contains_key(&addr));
10677
10678        // First disconnect: attempt 1
10679        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
10680        let next = attempt.saturating_add(1);
10681        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
10682        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
10683        backoff.insert(addr, (earliest, next));
10684        assert_eq!(backoff.get(&addr).unwrap().1, 1);
10685
10686        // Second disconnect: attempt 2
10687        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
10688        let next = attempt.saturating_add(1);
10689        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
10690        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
10691        backoff.insert(addr, (earliest, next));
10692        assert_eq!(backoff.get(&addr).unwrap().1, 2);
10693
10694        // Data received: clear
10695        backoff.remove(&addr);
10696        assert!(!backoff.contains_key(&addr));
10697    }
10698
10699    #[test]
10700    fn backoff_prevents_hammering() {
10701        // Verify that a peer with a future backoff time would be skipped.
10702        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
10703        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
10704
10705        // Set backoff 10 seconds in the future
10706        let future = std::time::Instant::now() + Duration::from_secs(10);
10707        backoff.insert(addr, (future, 3));
10708
10709        // Should be skipped (now < next_attempt)
10710        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
10711            assert!(std::time::Instant::now() < next_attempt);
10712        }
10713
10714        // Set backoff in the past — should NOT be skipped
10715        let past = std::time::Instant::now() - Duration::from_secs(1);
10716        backoff.insert(addr, (past, 3));
10717        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
10718            assert!(std::time::Instant::now() >= next_attempt);
10719        }
10720    }
10721
10722    #[test]
10723    fn max_in_flight_formula_updated() {
10724        // M104: max(512, connected*4) clamped to pieces/2, floored at 512.
10725        let formula = |connected: usize, num_pieces: u32| -> usize {
10726            let calculated = 512usize.max(connected.saturating_mul(4));
10727            calculated.min(num_pieces as usize / 2).max(512)
10728        };
10729
10730        // Few peers: floor dominates
10731        assert_eq!(formula(10, 2000), 512);
10732
10733        // Many peers: connected * 4 takes over
10734        assert_eq!(formula(200, 2000), 800);
10735
10736        // Very many peers: clamped by pieces/2
10737        assert_eq!(formula(500, 2000), 1000); // 2000 clamped to 1000
10738
10739        // Tiny torrent: floor dominates even with many peers
10740        assert_eq!(formula(200, 100), 512); // 800 clamped to 50, floored to 512
10741
10742        // Exact boundary
10743        assert_eq!(formula(128, 10000), 512); // 128*4=512, max(512,512)=512
10744        assert_eq!(formula(129, 10000), 516); // 129*4=516, max(512,516)=516
10745
10746        // Zero peers
10747        assert_eq!(formula(0, 10000), 512);
10748
10749        // Zero pieces (edge case — would give pieces/2=0, floor=512)
10750        assert_eq!(formula(100, 0), 512);
10751    }
10752
10753    // -- BEP 55 holepunch initiation tests (M112) --
10754
10755    #[test]
10756    fn should_attempt_holepunch_reason_classification() {
10757        // NAT-related reasons → true
10758        assert!(should_attempt_holepunch("connection refused"));
10759        assert!(should_attempt_holepunch("Connection refused"));
10760        assert!(should_attempt_holepunch("timed out"));
10761        assert!(should_attempt_holepunch("Connection reset by peer"));
10762        assert!(should_attempt_holepunch("connection reset by peer"));
10763        // Re-entrancy guard: holepunch-originated failures → false
10764        assert!(!should_attempt_holepunch(
10765            "holepunch TCP connect failed: Connection refused"
10766        ));
10767        // Non-NAT reasons → false
10768        assert!(!should_attempt_holepunch("peer banned"));
10769        assert!(!should_attempt_holepunch("protocol error"));
10770        assert!(!should_attempt_holepunch(""));
10771    }
10772
10773    #[test]
10774    fn holepunch_initiation_on_connect_failure() {
10775        // "connection refused" is the canonical NAT failure reason
10776        assert!(should_attempt_holepunch("connection refused"));
10777    }
10778
10779    #[test]
10780    fn holepunch_cooldown_prevents_retry() {
10781        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
10782        let addr: SocketAddr = "127.0.0.1:6881".parse().expect("valid test addr");
10783        let now = Instant::now();
10784        cooldowns.insert(addr, now);
10785        // addr is in cooldowns, so should be skipped on subsequent attempt
10786        assert!(cooldowns.contains_key(&addr));
10787    }
10788
10789    #[test]
10790    fn holepunch_cooldown_overflow_skips() {
10791        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
10792        let now = Instant::now();
10793        for i in 0..256u16 {
10794            let addr: SocketAddr = format!("10.0.{}.{}:6881", i / 256, i % 256)
10795                .parse()
10796                .expect("valid test addr");
10797            cooldowns.insert(addr, now);
10798        }
10799        assert_eq!(cooldowns.len(), HOLEPUNCH_MAX_TRACKED);
10800        // New entry should be skipped when at capacity
10801    }
10802
10803    #[test]
10804    fn holepunch_skipped_when_disabled() {
10805        // should_attempt_holepunch only checks the reason string, not config.
10806        // Config check happens in disconnect_peer.
10807        assert!(should_attempt_holepunch("connection refused"));
10808        // This test documents that should_attempt_holepunch is reason-only.
10809    }
10810
10811    #[test]
10812    fn holepunch_not_triggered_on_ban() {
10813        assert!(!should_attempt_holepunch("peer banned"));
10814        assert!(!should_attempt_holepunch("banned for bad data"));
10815    }
10816
10817    // -- M116: CachedFileInfo tests --
10818
10819    /// Helper to build a minimal `TorrentMetaV1` with multi-file entries.
10820    fn make_multi_file_meta(files: &[(u64, &str)], piece_length: u64) -> TorrentMetaV1 {
10821        let total_length: u64 = files.iter().map(|(len, _)| *len).sum();
10822        let num_pieces = total_length.div_ceil(piece_length) as usize;
10823        let file_entries: Vec<irontide_core::FileEntry> = files
10824            .iter()
10825            .map(|(length, name)| irontide_core::FileEntry {
10826                length: *length,
10827                path: vec![name.to_string()],
10828                attr: None,
10829                mtime: None,
10830                symlink_path: None,
10831            })
10832            .collect();
10833        TorrentMetaV1 {
10834            info_hash: Id20([0u8; 20]),
10835            announce: None,
10836            announce_list: None,
10837            comment: None,
10838            created_by: None,
10839            creation_date: None,
10840            info: irontide_core::InfoDict {
10841                name: "test".to_string(),
10842                piece_length,
10843                pieces: vec![0u8; num_pieces * 20],
10844                length: None,
10845                files: Some(file_entries),
10846                private: None,
10847                source: None,
10848                ssl_cert: None,
10849                similar: Vec::new(),
10850                collections: Vec::new(),
10851            },
10852            url_list: Vec::new(),
10853            httpseeds: Vec::new(),
10854            info_bytes: None,
10855            ssl_cert: None,
10856        }
10857    }
10858
10859    #[test]
10860    fn cached_files_populated_on_registration() {
10861        // 3 files: 100, 200, 50 bytes; piece_length = 100
10862        // Total = 350 bytes, 4 pieces (0..3)
10863        // File 0: offset 0..100  -> pieces [0, 0]
10864        // File 1: offset 100..300 -> pieces [1, 2]
10865        // File 2: offset 300..350 -> pieces [3, 3]
10866        let meta = make_multi_file_meta(&[(100, "a.txt"), (200, "b.txt"), (50, "c.txt")], 100);
10867        let lengths = Lengths::new(350, 100, 16384);
10868        let cached = build_cached_file_info(&meta, &lengths);
10869
10870        assert_eq!(cached.entries.len(), 3);
10871
10872        assert_eq!(cached.entries[0].index, 0);
10873        assert_eq!(cached.entries[0].length, 100);
10874        assert_eq!(cached.entries[0].first_piece, 0);
10875        assert_eq!(cached.entries[0].last_piece, 0);
10876
10877        assert_eq!(cached.entries[1].index, 1);
10878        assert_eq!(cached.entries[1].length, 200);
10879        assert_eq!(cached.entries[1].first_piece, 1);
10880        assert_eq!(cached.entries[1].last_piece, 2);
10881
10882        assert_eq!(cached.entries[2].index, 2);
10883        assert_eq!(cached.entries[2].length, 50);
10884        assert_eq!(cached.entries[2].first_piece, 3);
10885        assert_eq!(cached.entries[2].last_piece, 3);
10886    }
10887
10888    #[test]
10889    fn cached_files_single_file_torrent() {
10890        // Single-file torrent: 500 bytes, piece_length = 100
10891        // 5 pieces (0..4)
10892        let meta = TorrentMetaV1 {
10893            info_hash: Id20([0u8; 20]),
10894            announce: None,
10895            announce_list: None,
10896            comment: None,
10897            created_by: None,
10898            creation_date: None,
10899            info: irontide_core::InfoDict {
10900                name: "single.bin".to_string(),
10901                piece_length: 100,
10902                pieces: vec![0u8; 5 * 20],
10903                length: Some(500),
10904                files: None,
10905                private: None,
10906                source: None,
10907                ssl_cert: None,
10908                similar: Vec::new(),
10909                collections: Vec::new(),
10910            },
10911            url_list: Vec::new(),
10912            httpseeds: Vec::new(),
10913            info_bytes: None,
10914            ssl_cert: None,
10915        };
10916        let lengths = Lengths::new(500, 100, 16384);
10917        let cached = build_cached_file_info(&meta, &lengths);
10918
10919        assert_eq!(cached.entries.len(), 1);
10920        assert_eq!(cached.entries[0].index, 0);
10921        assert_eq!(cached.entries[0].length, 500);
10922        assert_eq!(cached.entries[0].first_piece, 0);
10923        assert_eq!(cached.entries[0].last_piece, 4);
10924    }
10925
10926    // ── M132: Time-based steal-queue population tests ──
10927    //
10928    // These tests verify the steal-populate logic that runs in run_steal_queue_maintenance().
10929    // They build AtomicPieceStates and StealCandidates directly and exercise the
10930    // same scan loop used by the real implementation.
10931
10932    use crate::piece_reservation::{AtomicPieceStates, PieceState, StealCandidates};
10933    use irontide_storage::Bitfield;
10934
10935    /// Helper: run the steal-populate scan (mirrors `run_steal_queue_maintenance`).
10936    ///
10937    /// Returns the number of pieces pushed into the steal queue.
10938    fn steal_populate_scan(states: &AtomicPieceStates, sc: &StealCandidates) -> u32 {
10939        let mut pushed = 0u32;
10940        let num = states.len();
10941        for piece in 0..num {
10942            let state = states.get(piece);
10943            if state == PieceState::Reserved {
10944                sc.push(piece);
10945                pushed = pushed.saturating_add(1);
10946            }
10947        }
10948        pushed
10949    }
10950
10951    fn all_wanted(n: u32) -> Bitfield {
10952        let mut bf = Bitfield::new(n);
10953        for i in 0..n {
10954            bf.set(i);
10955        }
10956        bf
10957    }
10958
10959    #[test]
10960    fn steal_populate_pushes_reserved_pieces() {
10961        let n = 10;
10962        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
10963        let sc = StealCandidates::new();
10964
10965        // Reserve pieces 2, 5, 7
10966        assert!(states.try_reserve(2));
10967        assert!(states.try_reserve(5));
10968        assert!(states.try_reserve(7));
10969
10970        let pushed = steal_populate_scan(&states, &sc);
10971        assert_eq!(pushed, 3, "should push exactly the 3 reserved pieces");
10972
10973        // Verify they're in the queue
10974        let mut popped = Vec::new();
10975        while let Some(p) = sc.pop() {
10976            popped.push(p);
10977        }
10978        popped.sort_unstable();
10979        assert_eq!(popped, vec![2, 5, 7]);
10980    }
10981
10982    #[test]
10983    fn steal_populate_skips_non_reserved_states() {
10984        let n = 8;
10985        let mut have = Bitfield::new(n);
10986        have.set(0); // piece 0 = Complete
10987        let mut wanted = all_wanted(n);
10988        wanted.clear(1); // piece 1 = Unwanted
10989
10990        let states = AtomicPieceStates::new(n, &have, &wanted);
10991        let sc = StealCandidates::new();
10992
10993        // Reserve piece 3, leave rest as Available/Complete/Unwanted
10994        assert!(states.try_reserve(3));
10995
10996        let pushed = steal_populate_scan(&states, &sc);
10997        assert_eq!(pushed, 1, "only piece 3 (Reserved) should be pushed");
10998
10999        assert_eq!(sc.pop(), Some(3));
11000        assert_eq!(sc.pop(), None);
11001    }
11002
11003    #[test]
11004    fn steal_populate_deduplicates() {
11005        let n = 4;
11006        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11007        let sc = StealCandidates::new();
11008
11009        assert!(states.try_reserve(1));
11010        assert!(states.try_reserve(2));
11011
11012        // First scan pushes 2 pieces
11013        let pushed1 = steal_populate_scan(&states, &sc);
11014        assert_eq!(pushed1, 2);
11015
11016        // Second scan: StealCandidates.push() deduplicates, so the queue
11017        // should still contain exactly 2 entries, not 4.
11018        let pushed2 = steal_populate_scan(&states, &sc);
11019        assert_eq!(pushed2, 2, "scan still reports 2 reserved pieces");
11020
11021        let mut count = 0u32;
11022        while sc.pop().is_some() {
11023            count = count.saturating_add(1);
11024        }
11025        assert_eq!(count, 2, "dedup means only 2 entries despite 2 scans");
11026    }
11027
11028    #[test]
11029    fn steal_populate_skips_completed_pieces() {
11030        let n = 5;
11031        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11032        let sc = StealCandidates::new();
11033
11034        // Reserve all 5 pieces
11035        for i in 0..n {
11036            assert!(states.try_reserve(i));
11037        }
11038
11039        // Complete pieces 1 and 3 before the scan
11040        states.mark_complete(1);
11041        states.mark_complete(3);
11042
11043        let pushed = steal_populate_scan(&states, &sc);
11044        assert_eq!(pushed, 3, "3 pieces still Reserved (0, 2, 4)");
11045
11046        let mut popped = Vec::new();
11047        while let Some(p) = sc.pop() {
11048            popped.push(p);
11049        }
11050        popped.sort_unstable();
11051        assert_eq!(popped, vec![0, 2, 4]);
11052    }
11053
11054    #[test]
11055    fn steal_populate_empty_when_no_reserved() {
11056        let n = 6;
11057        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11058        let sc = StealCandidates::new();
11059
11060        // No pieces reserved — scan should push nothing
11061        let pushed = steal_populate_scan(&states, &sc);
11062        assert_eq!(pushed, 0);
11063        assert_eq!(sc.pop(), None);
11064    }
11065
11066    #[test]
11067    fn steal_populate_with_endgame_pieces() {
11068        // Endgame pieces (state = Endgame) should NOT be pushed — only Reserved.
11069        let n = 4;
11070        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11071        let sc = StealCandidates::new();
11072
11073        assert!(states.try_reserve(0));
11074        assert!(states.try_reserve(1));
11075        states.transition_to_endgame(1);
11076
11077        let pushed = steal_populate_scan(&states, &sc);
11078        assert_eq!(
11079            pushed, 1,
11080            "only piece 0 (Reserved) should be pushed, not piece 1 (Endgame)"
11081        );
11082        assert_eq!(sc.pop(), Some(0));
11083        assert_eq!(sc.pop(), None);
11084    }
11085
11086    // -------------------------------------------------------------------
11087    // F8: Piece state sync on file priority change
11088    // -------------------------------------------------------------------
11089
11090    #[test]
11091    fn sync_piece_states_marks_unwanted_on_skip() {
11092        let n = 8;
11093        let mut wanted = all_wanted(n);
11094        wanted.clear(2);
11095        wanted.clear(3);
11096        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11097        // Simulate: wanted_pieces was rebuilt but atomic_states not yet synced.
11098        // Pieces 2 and 3 are Available but no longer wanted.
11099        assert_eq!(states.get(2), PieceState::Available);
11100        assert_eq!(states.get(3), PieceState::Available);
11101
11102        // Run the sync logic directly.
11103        for piece in 0..n {
11104            let w = wanted.get(piece);
11105            let current = states.get(piece);
11106            if !w && current == PieceState::Available {
11107                states.mark_unwanted(piece);
11108            } else if w && current == PieceState::Unwanted {
11109                states.mark_available(piece);
11110            }
11111        }
11112
11113        assert_eq!(states.get(0), PieceState::Available);
11114        assert_eq!(states.get(2), PieceState::Unwanted);
11115        assert_eq!(states.get(3), PieceState::Unwanted);
11116        assert_eq!(states.get(4), PieceState::Available);
11117    }
11118
11119    #[test]
11120    fn sync_piece_states_restores_available_on_unskip() {
11121        let n = 6;
11122        let mut initial_wanted = all_wanted(n);
11123        initial_wanted.clear(1);
11124        initial_wanted.clear(4);
11125        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &initial_wanted);
11126        assert_eq!(states.get(1), PieceState::Unwanted);
11127        assert_eq!(states.get(4), PieceState::Unwanted);
11128
11129        // Now re-enable all pieces (simulate setting back to Normal).
11130        let new_wanted = all_wanted(n);
11131        for piece in 0..n {
11132            let w = new_wanted.get(piece);
11133            let current = states.get(piece);
11134            if !w && current == PieceState::Available {
11135                states.mark_unwanted(piece);
11136            } else if w && current == PieceState::Unwanted {
11137                states.mark_available(piece);
11138            }
11139        }
11140
11141        assert_eq!(states.get(1), PieceState::Available);
11142        assert_eq!(states.get(4), PieceState::Available);
11143    }
11144
11145    #[test]
11146    fn sync_piece_states_shared_piece_stays_available() {
11147        // A piece spanning a skipped and non-skipped file stays wanted
11148        // (build_wanted_pieces marks it wanted if any spanning file is
11149        // non-skip). Verify the sync leaves it Available.
11150        let n = 4;
11151        let mut wanted = all_wanted(n);
11152        wanted.clear(0); // exclusive to skipped file
11153        // Piece 1 is shared — stays wanted
11154        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11155
11156        for piece in 0..n {
11157            let w = wanted.get(piece);
11158            let current = states.get(piece);
11159            if !w && current == PieceState::Available {
11160                states.mark_unwanted(piece);
11161            } else if w && current == PieceState::Unwanted {
11162                states.mark_available(piece);
11163            }
11164        }
11165
11166        assert_eq!(states.get(0), PieceState::Unwanted);
11167        assert_eq!(
11168            states.get(1),
11169            PieceState::Available,
11170            "shared piece stays Available"
11171        );
11172        assert_eq!(states.get(2), PieceState::Available);
11173        assert_eq!(states.get(3), PieceState::Available);
11174    }
11175
11176    // -------------------------------------------------------------------
11177    // M133: DHT re-query tests
11178    // -------------------------------------------------------------------
11179
11180    /// Verify the DHT re-query guard scales with `max_peers` config.
11181    ///
11182    /// The guard threshold is `max_peers * 4`. With default `max_peers = 128`,
11183    /// this becomes 512 (close to the old hardcoded 500).
11184    #[test]
11185    fn dht_requery_guard_scales_with_max_peers() {
11186        // max_peers = 128 → threshold = 512
11187        assert_eq!(128_usize.saturating_mul(4), 512);
11188
11189        // max_peers = 200 → threshold = 800
11190        assert_eq!(200_usize.saturating_mul(4), 800);
11191
11192        // max_peers = 50 → threshold = 200
11193        assert_eq!(50_usize.saturating_mul(4), 200);
11194
11195        // Overflow protection: saturating_mul handles usize::MAX
11196        assert_eq!(usize::MAX.saturating_mul(4), usize::MAX);
11197    }
11198
11199    // ---- M147: Pre-resolved metadata tests ----
11200
11201    /// Build a raw bencoded info dict and its SHA1 hash (for magnet link testing).
11202    fn make_test_info_bytes() -> (Vec<u8>, Id20) {
11203        use serde::Serialize;
11204
11205        #[derive(Serialize)]
11206        struct Info<'a> {
11207            length: u64,
11208            name: &'a str,
11209            #[serde(rename = "piece length")]
11210            piece_length: u64,
11211            #[serde(with = "serde_bytes")]
11212            pieces: &'a [u8],
11213        }
11214
11215        let data = vec![0xAB; 1024];
11216        let piece_hash = irontide_core::sha1(&data);
11217        let mut pieces = Vec::new();
11218        pieces.extend_from_slice(piece_hash.as_bytes());
11219
11220        let info = Info {
11221            length: 1024,
11222            name: "test",
11223            piece_length: 16384,
11224            pieces: &pieces,
11225        };
11226
11227        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
11228        let info_hash = irontide_core::sha1(&info_bytes);
11229        (info_bytes, info_hash)
11230    }
11231
11232    /// Create a magnet-based `TorrentHandle` for testing `PreResolvedMetadata`.
11233    async fn create_magnet_handle(info_hash: Id20) -> TorrentHandle {
11234        let magnet = Magnet {
11235            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
11236            display_name: Some("test".into()),
11237            trackers: vec![],
11238            peers: vec![],
11239            selected_files: None,
11240        };
11241        let config = test_config();
11242        let (atx, amask) = test_alert_channel();
11243        let (dm, _dj) = test_disk_manager();
11244        TorrentHandle::from_magnet(
11245            magnet,
11246            dm,
11247            config,
11248            test_dht_rx(),
11249            test_dht_rx(),
11250            None,
11251            None,
11252            crate::slot_tuner::SlotTuner::disabled(4),
11253            atx,
11254            amask,
11255            None,
11256            None,
11257            test_ban_manager(),
11258            test_ip_filter(),
11259            Arc::new(Vec::new()),
11260            None,
11261            None,
11262            Arc::new(crate::transport::NetworkFactory::tokio()),
11263            None,
11264            Arc::new(crate::stats::SessionCounters::new()),
11265        )
11266        .await
11267        .unwrap()
11268    }
11269
11270    #[tokio::test]
11271    async fn pre_resolved_metadata_applies_when_fetching() {
11272        let (info_bytes, info_hash) = make_test_info_bytes();
11273        let handle = create_magnet_handle(info_hash).await;
11274
11275        // Verify we start in FetchingMetadata state.
11276        let stats = handle.stats().await.unwrap();
11277        assert_eq!(stats.state, TorrentState::FetchingMetadata);
11278
11279        // Send pre-resolved metadata (with a fake peer for pre-seeding).
11280        let peer_addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
11281        handle.send_pre_resolved_metadata(info_bytes, vec![peer_addr]);
11282
11283        // Give the actor time to process the command.
11284        tokio::time::sleep(Duration::from_millis(200)).await;
11285
11286        // Verify transition to Downloading state.
11287        let stats = handle.stats().await.unwrap();
11288        assert_eq!(
11289            stats.state,
11290            TorrentState::Downloading,
11291            "should have transitioned to Downloading after pre-resolved metadata"
11292        );
11293        assert!(
11294            stats.pieces_total > 0,
11295            "should know piece count after metadata resolution"
11296        );
11297
11298        handle.shutdown().await.unwrap();
11299    }
11300
11301    #[tokio::test]
11302    async fn pre_resolved_metadata_ignored_after_resolution() {
11303        // Create a .torrent-based handle (already in Downloading state).
11304        let data = vec![0xAB; 32768];
11305        let meta = make_test_torrent(&data, 16384);
11306        let info_hash = meta.info_hash;
11307        let storage = make_storage(&data, 16384);
11308        let config = test_config();
11309
11310        let (atx, amask) = test_alert_channel();
11311        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
11312        let handle = TorrentHandle::from_torrent(
11313            meta,
11314            irontide_core::TorrentVersion::V1Only,
11315            None,
11316            dh,
11317            dm,
11318            config,
11319            test_dht_rx(),
11320            test_dht_rx(),
11321            None,
11322            None,
11323            crate::slot_tuner::SlotTuner::disabled(4),
11324            atx,
11325            amask,
11326            None,
11327            None,
11328            test_ban_manager(),
11329            test_ip_filter(),
11330            Arc::new(Vec::new()),
11331            None,
11332            None,
11333            Arc::new(crate::transport::NetworkFactory::tokio()),
11334            None,
11335            Arc::new(crate::stats::SessionCounters::new()),
11336        )
11337        .await
11338        .unwrap();
11339
11340        let stats_before = handle.stats().await.unwrap();
11341        assert_eq!(stats_before.state, TorrentState::Downloading);
11342
11343        // Send pre-resolved metadata — should be silently ignored since
11344        // the actor is already past FetchingMetadata.
11345        let (info_bytes, _) = make_test_info_bytes();
11346        handle.send_pre_resolved_metadata(info_bytes, vec![]);
11347
11348        // Give the actor time to process (or ignore) the command.
11349        tokio::time::sleep(Duration::from_millis(100)).await;
11350
11351        // Verify state hasn't changed and no crash occurred.
11352        let stats_after = handle.stats().await.unwrap();
11353        assert_eq!(stats_after.state, TorrentState::Downloading);
11354        assert_eq!(stats_after.pieces_total, stats_before.pieces_total);
11355
11356        handle.shutdown().await.unwrap();
11357    }
11358
11359    #[tokio::test]
11360    async fn pre_resolved_metadata_with_invalid_hash_stays_fetching() {
11361        // Build info bytes with a WRONG info_hash — the SHA1 won't match
11362        // the magnet link's info_hash, so try_assemble_metadata should
11363        // fail verification and the actor should stay in FetchingMetadata.
11364        let (info_bytes, _correct_hash) = make_test_info_bytes();
11365
11366        // Use a different (wrong) info_hash for the magnet.
11367        let wrong_hash = Id20::from_hex("0000000000000000000000000000000000000001").unwrap();
11368        let handle = create_magnet_handle(wrong_hash).await;
11369
11370        let stats = handle.stats().await.unwrap();
11371        assert_eq!(stats.state, TorrentState::FetchingMetadata);
11372
11373        // Send metadata with mismatched hash — should fail verification.
11374        handle.send_pre_resolved_metadata(info_bytes, vec![]);
11375
11376        tokio::time::sleep(Duration::from_millis(200)).await;
11377
11378        // Actor should remain in FetchingMetadata (verification failed).
11379        let stats = handle.stats().await.unwrap();
11380        assert_eq!(
11381            stats.state,
11382            TorrentState::FetchingMetadata,
11383            "should stay in FetchingMetadata when info_hash doesn't match"
11384        );
11385
11386        handle.shutdown().await.unwrap();
11387    }
11388
11389    #[test]
11390    fn initial_queue_depth_is_128() {
11391        use crate::peer_shared::INITIAL_QUEUE_DEPTH;
11392        assert_eq!(INITIAL_QUEUE_DEPTH, 128);
11393    }
11394
11395    // ---- M159: seed mode scheduling-suppression integration test ----
11396
11397    /// End-to-end test that seed mode actually suppresses new block request
11398    /// dispatch at the wire level.
11399    ///
11400    /// 1. Spin up a 2-piece torrent with no downloaded data.
11401    /// 2. Connect a mock seeder that advertises both pieces.
11402    /// 3. Wait for the actor to send at least one `Request` (normal dispatch).
11403    /// 4. Flip `set_seed_mode(true)`.
11404    /// 5. Observe that a `Cancel` is sent for the pending request, and that
11405    ///    no additional `Request` messages arrive within 500 ms.
11406    /// 6. Confirm the stats snapshot reflects `user_seed_mode == true`.
11407    #[tokio::test]
11408    #[allow(
11409        clippy::large_stack_arrays,
11410        reason = "test data buffer passed directly to make_storage"
11411    )]
11412    async fn m159_seed_mode_suppresses_new_requests_on_wire() {
11413        let data = vec![0xAB; 32768]; // 32 KiB
11414        let meta = make_test_torrent(&data, 16384); // 2 pieces
11415        let info_hash = meta.info_hash;
11416        // Leecher has empty storage — wants both pieces.
11417        let storage = make_storage(&[0u8; 32768], 16384);
11418
11419        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
11420        let listen_addr = listener.local_addr().unwrap();
11421        let config = TorrentConfig {
11422            listen_port: listen_addr.port(),
11423            ..test_config()
11424        };
11425        drop(listener);
11426
11427        let (atx, amask) = test_alert_channel();
11428        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
11429        let handle = TorrentHandle::from_torrent(
11430            meta,
11431            irontide_core::TorrentVersion::V1Only,
11432            None,
11433            dh,
11434            dm,
11435            config,
11436            test_dht_rx(),
11437            test_dht_rx(),
11438            None,
11439            None,
11440            crate::slot_tuner::SlotTuner::disabled(4),
11441            atx,
11442            amask,
11443            None,
11444            None,
11445            test_ban_manager(),
11446            test_ip_filter(),
11447            Arc::new(Vec::new()),
11448            None,
11449            None,
11450            Arc::new(crate::transport::NetworkFactory::tokio()),
11451            None,
11452            Arc::new(crate::stats::SessionCounters::new()),
11453        )
11454        .await
11455        .unwrap();
11456
11457        tokio::time::sleep(Duration::from_millis(50)).await;
11458
11459        // Connect a mock seeder to the actor's listener.
11460        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
11461        let (reader, writer) = tokio::io::split(stream);
11462        let mut writer = writer;
11463        let mut reader = reader;
11464
11465        let hs = Handshake::new(
11466            info_hash,
11467            Id20::from_hex("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
11468        );
11469        writer.write_all(&hs.to_bytes()).await.unwrap();
11470        writer.flush().await.unwrap();
11471        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
11472        reader.read_exact(&mut hs_buf).await.unwrap();
11473
11474        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
11475        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
11476
11477        // Drain the actor's ext handshake, then send ours.
11478        let _actor_ext_hs = framed_read.next().await;
11479        let ext_hs = ExtHandshake::new();
11480        let ext_payload = ext_hs.to_bytes().unwrap();
11481        framed_write
11482            .send(Message::Extended {
11483                ext_id: 0,
11484                payload: ext_payload,
11485            })
11486            .await
11487            .unwrap();
11488
11489        // Announce that we (the mock seeder) have both pieces.
11490        let mut bf = Bitfield::new(2);
11491        bf.set(0);
11492        bf.set(1);
11493        framed_write
11494            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
11495            .await
11496            .unwrap();
11497        framed_write.send(Message::Unchoke).await.unwrap();
11498
11499        // Wait for the actor to send its first Request (and any adjacent ones
11500        // inside one select tick). This confirms the normal dispatch path is
11501        // engaged before we flip into seed mode.
11502        let mut initial_request_seen = false;
11503        let wait_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
11504        loop {
11505            let remaining = wait_deadline.saturating_duration_since(tokio::time::Instant::now());
11506            if remaining.is_zero() {
11507                break;
11508            }
11509            match tokio::time::timeout(remaining, framed_read.next()).await {
11510                Ok(Some(Ok(Message::Request { .. }))) => {
11511                    initial_request_seen = true;
11512                    break;
11513                }
11514                Ok(Some(Ok(_))) => {}
11515                _ => break,
11516            }
11517        }
11518        assert!(
11519            initial_request_seen,
11520            "actor should have sent a Request before seed mode toggle"
11521        );
11522
11523        // Flip user seed mode on. From this point forward the actor must not
11524        // dispatch any new Request messages.
11525        handle.set_seed_mode(true).await.unwrap();
11526
11527        // There's an inherent race between the actor processing the toggle
11528        // and the per-peer requester loop receiving its `DispatchCommand::Stop`
11529        // — a block may already be in the writer's queue when we flip. Drain
11530        // for a brief grace window, then verify the dispatch has fully halted
11531        // for a second longer window: if scheduling is truly suppressed, no
11532        // Request messages will arrive during the steady-state window.
11533        let grace_deadline = tokio::time::Instant::now() + Duration::from_millis(200);
11534        let mut cancel_seen = false;
11535        let mut grace_requests = 0u32;
11536        loop {
11537            let remaining = grace_deadline.saturating_duration_since(tokio::time::Instant::now());
11538            if remaining.is_zero() {
11539                break;
11540            }
11541            match tokio::time::timeout(remaining, framed_read.next()).await {
11542                Ok(Some(Ok(Message::Request { .. }))) => {
11543                    grace_requests += 1;
11544                }
11545                Ok(Some(Ok(Message::Cancel { .. }))) => {
11546                    cancel_seen = true;
11547                }
11548                Ok(Some(Ok(_))) => {}
11549                Ok(None | Some(Err(_))) | Err(_) => break,
11550            }
11551        }
11552        let _ = (cancel_seen, grace_requests);
11553
11554        // Steady-state window: if the dispatch path is really gated, zero
11555        // new Request messages must arrive for the next 500 ms.
11556        let steady_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
11557        let mut steady_requests = 0u32;
11558        loop {
11559            let remaining = steady_deadline.saturating_duration_since(tokio::time::Instant::now());
11560            if remaining.is_zero() {
11561                break;
11562            }
11563            match tokio::time::timeout(remaining, framed_read.next()).await {
11564                Ok(Some(Ok(Message::Request { .. }))) => {
11565                    steady_requests += 1;
11566                }
11567                Ok(Some(Ok(_))) => {}
11568                Ok(None | Some(Err(_))) | Err(_) => break,
11569            }
11570        }
11571
11572        assert_eq!(
11573            steady_requests, 0,
11574            "after the Stop propagation grace window, no new Request messages \
11575             must appear during steady-state while user_seed_mode is active"
11576        );
11577
11578        // Stats should reflect the flag.
11579        let stats = handle.stats().await.unwrap();
11580        assert!(
11581            stats.user_seed_mode,
11582            "stats.user_seed_mode should be true after set_seed_mode(true)"
11583        );
11584
11585        handle.shutdown().await.unwrap();
11586    }
11587
11588    // ---- M159 Task 1: Wire-level test — uploads continue in seed mode ----
11589    //
11590    // The point of user seed mode is to stop *downloading* (suppress new
11591    // block requests we issue to peers) while still *uploading* (honouring
11592    // incoming `Request` messages from peers who want pieces we have).
11593    // The companion test `m159_seed_mode_suppresses_new_requests_on_wire`
11594    // covers the download-suppression half; this one closes the loop by
11595    // asserting that the upload path survives a seed-mode toggle.
11596    //
11597    // Test shape:
11598    //   1. Pre-seed storage with two verified pieces (actor starts in
11599    //      `Seeding` state because `make_seeded_storage` writes the full
11600    //      dataset before the actor runs initial verification).
11601    //   2. Flip `user_seed_mode` on via `set_seed_mode(true)`. This is the
11602    //      load-bearing step — uploads must still work *after* seed mode
11603    //      is enabled.
11604    //   3. Connect a fake leecher via a real `TcpListener`, complete the
11605    //      BT + extended handshake.
11606    //   4. Announce an empty bitfield and send `Interested`. The choker
11607    //      still runs in seed mode, so the actor must respond with
11608    //      `Unchoke` (seed-mode choking algorithms unchoke interested
11609    //      peers based on upload throughput — a brand-new peer that just
11610    //      sent Interested is a valid candidate).
11611    //   5. Send `Request { index: 0, begin: 0, length: 16384 }` and assert
11612    //      a matching `Piece` message arrives on the wire within 2s, with
11613    //      a payload of the correct length and filled with the pre-seeded
11614    //      byte pattern.
11615    #[tokio::test]
11616    async fn m159_seed_mode_uploads_continue_on_wire() {
11617        const FILL_BYTE: u8 = 0x5A;
11618        const PIECE_LENGTH: u64 = 16384;
11619        const TOTAL_LEN: usize = 32768; // 2 pieces
11620
11621        let data = vec![FILL_BYTE; TOTAL_LEN];
11622        let meta = make_test_torrent(&data, PIECE_LENGTH);
11623        let info_hash = meta.info_hash;
11624        // Pre-seeded storage — actor transitions to Seeding after verify.
11625        let storage = make_seeded_storage(&data, PIECE_LENGTH);
11626
11627        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
11628        let listen_addr = listener.local_addr().unwrap();
11629        let config = TorrentConfig {
11630            listen_port: listen_addr.port(),
11631            ..test_config()
11632        };
11633        drop(listener);
11634
11635        let (atx, amask) = test_alert_channel();
11636        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
11637        let handle = TorrentHandle::from_torrent(
11638            meta,
11639            irontide_core::TorrentVersion::V1Only,
11640            None,
11641            dh,
11642            dm,
11643            config,
11644            test_dht_rx(),
11645            test_dht_rx(),
11646            None,
11647            None,
11648            crate::slot_tuner::SlotTuner::disabled(4),
11649            atx,
11650            amask,
11651            None,
11652            None,
11653            test_ban_manager(),
11654            test_ip_filter(),
11655            Arc::new(Vec::new()),
11656            None,
11657            None,
11658            Arc::new(crate::transport::NetworkFactory::tokio()),
11659            None,
11660            Arc::new(crate::stats::SessionCounters::new()),
11661        )
11662        .await
11663        .unwrap();
11664
11665        // Wait for initial verification to complete so the actor is really
11666        // in Seeding state before we flip seed mode. Poll stats up to 3s.
11667        let seeding_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
11668        loop {
11669            tokio::time::sleep(Duration::from_millis(50)).await;
11670            let stats = handle.stats().await.unwrap();
11671            if stats.state == TorrentState::Seeding && stats.pieces_have == 2 {
11672                break;
11673            }
11674            if tokio::time::Instant::now() > seeding_deadline {
11675                let stats = handle.stats().await.unwrap();
11676                panic!(
11677                    "actor did not reach Seeding state within 3s: state={:?}, have={}/{}",
11678                    stats.state, stats.pieces_have, stats.pieces_total
11679                );
11680            }
11681        }
11682
11683        // Flip user seed mode on. The upload path must continue to serve
11684        // incoming Request messages from this point forward.
11685        handle.set_seed_mode(true).await.unwrap();
11686        let stats = handle.stats().await.unwrap();
11687        assert!(
11688            stats.user_seed_mode,
11689            "stats.user_seed_mode should be true after set_seed_mode(true)"
11690        );
11691
11692        // Connect a mock leecher to the actor's listener.
11693        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
11694        let (reader, writer) = tokio::io::split(stream);
11695        let mut writer = writer;
11696        let mut reader = reader;
11697
11698        let hs = Handshake::new(
11699            info_hash,
11700            Id20::from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap(),
11701        );
11702        writer.write_all(&hs.to_bytes()).await.unwrap();
11703        writer.flush().await.unwrap();
11704        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
11705        reader.read_exact(&mut hs_buf).await.unwrap();
11706
11707        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
11708        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
11709
11710        // Drain the actor's ext handshake, then send ours.
11711        let _actor_ext_hs = framed_read.next().await;
11712        let ext_hs = ExtHandshake::new();
11713        let ext_payload = ext_hs.to_bytes().unwrap();
11714        framed_write
11715            .send(Message::Extended {
11716                ext_id: 0,
11717                payload: ext_payload,
11718            })
11719            .await
11720            .unwrap();
11721
11722        // Tell the actor we (the mock leecher) have nothing.
11723        let bf = Bitfield::new(2);
11724        framed_write
11725            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
11726            .await
11727            .unwrap();
11728        framed_write.send(Message::Interested).await.unwrap();
11729
11730        // Wait for Unchoke from the actor. The actor may also send its own
11731        // Bitfield/Have/Extended/Choke/etc.; we drain non-Unchoke messages
11732        // until we see it (or time out).
11733        let unchoke_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
11734        let mut saw_unchoke = false;
11735        loop {
11736            let remaining = unchoke_deadline.saturating_duration_since(tokio::time::Instant::now());
11737            if remaining.is_zero() {
11738                break;
11739            }
11740            match tokio::time::timeout(remaining, framed_read.next()).await {
11741                Ok(Some(Ok(Message::Unchoke))) => {
11742                    saw_unchoke = true;
11743                    break;
11744                }
11745                Ok(Some(Ok(_))) => {}
11746                Ok(None | Some(Err(_))) => break,
11747                Err(_elapsed) => break,
11748            }
11749        }
11750        assert!(
11751            saw_unchoke,
11752            "actor should have unchoked the leecher while user_seed_mode is active"
11753        );
11754
11755        // Request piece 0, full 16 KiB block. The actor is seeding with
11756        // seed mode on — it must still serve this upload.
11757        framed_write
11758            .send(Message::Request {
11759                index: 0,
11760                begin: 0,
11761                length: PIECE_LENGTH as u32,
11762            })
11763            .await
11764            .unwrap();
11765
11766        // Expect a Piece message to arrive on the wire with matching
11767        // index/begin and the correct payload. Drain any other messages
11768        // (Have, Bitfield updates, Choke refreshes, etc.) that may arrive
11769        // first.
11770        let piece_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
11771        let mut got_piece = false;
11772        loop {
11773            let remaining = piece_deadline.saturating_duration_since(tokio::time::Instant::now());
11774            if remaining.is_zero() {
11775                break;
11776            }
11777            match tokio::time::timeout(remaining, framed_read.next()).await {
11778                Ok(Some(Ok(Message::Piece {
11779                    index,
11780                    begin,
11781                    data_0,
11782                    data_1,
11783                }))) => {
11784                    assert_eq!(index, 0, "Piece index should match request");
11785                    assert_eq!(begin, 0, "Piece begin should match request");
11786                    let mut payload: Vec<u8> =
11787                        Vec::with_capacity(data_0.len().saturating_add(data_1.len()));
11788                    payload.extend_from_slice(&data_0);
11789                    payload.extend_from_slice(&data_1);
11790                    assert_eq!(
11791                        payload.len(),
11792                        PIECE_LENGTH as usize,
11793                        "Piece payload length should match requested length"
11794                    );
11795                    assert!(
11796                        payload.iter().all(|&b| b == FILL_BYTE),
11797                        "Piece payload should contain the pre-seeded fill byte"
11798                    );
11799                    got_piece = true;
11800                    break;
11801                }
11802                Ok(Some(Ok(_))) => {}
11803                Ok(None | Some(Err(_))) => break,
11804                Err(_elapsed) => break,
11805            }
11806        }
11807        assert!(
11808            got_piece,
11809            "actor should have served a Piece in response to Request while user_seed_mode is active"
11810        );
11811
11812        // Stats should still reflect the seed-mode flag and accumulated
11813        // upload bytes for the one block we served.
11814        let stats = handle.stats().await.unwrap();
11815        assert!(
11816            stats.user_seed_mode,
11817            "stats.user_seed_mode should remain true after serving an upload"
11818        );
11819        assert!(
11820            stats.uploaded >= u64::from(PIECE_LENGTH as u32),
11821            "stats.uploaded should reflect the served block, got {}",
11822            stats.uploaded
11823        );
11824
11825        handle.shutdown().await.unwrap();
11826    }
11827
11828    // ---- M161: info dict, v2 hash, and timestamp tests ----
11829
11830    #[tokio::test]
11831    async fn info_field_populated_for_torrent() {
11832        let data = vec![0xAB; 32768];
11833        let meta = make_test_torrent(&data, 16384);
11834        let storage = make_storage(&data, 16384);
11835        let config = test_config();
11836
11837        let (atx, amask) = test_alert_channel();
11838        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11839        let handle = TorrentHandle::from_torrent(
11840            meta,
11841            irontide_core::TorrentVersion::V1Only,
11842            None,
11843            dh,
11844            dm,
11845            config,
11846            test_dht_rx(),
11847            test_dht_rx(),
11848            None,
11849            None,
11850            crate::slot_tuner::SlotTuner::disabled(4),
11851            atx,
11852            amask,
11853            None,
11854            None,
11855            test_ban_manager(),
11856            test_ip_filter(),
11857            Arc::new(Vec::new()),
11858            None,
11859            None,
11860            Arc::new(crate::transport::NetworkFactory::tokio()),
11861            None,
11862            Arc::new(crate::stats::SessionCounters::new()),
11863        )
11864        .await
11865        .unwrap();
11866
11867        tokio::time::sleep(Duration::from_millis(50)).await;
11868
11869        let rd = handle.save_resume_data().await.unwrap();
11870
11871        // info field must be populated when metadata is available
11872        assert!(rd.info.is_some(), "rd.info should be Some for .torrent");
11873
11874        // The embedded bytes must deserialize back to a valid InfoDict
11875        let info_bytes = rd.info.as_ref().unwrap();
11876        let info: irontide_core::InfoDict =
11877            irontide_bencode::from_bytes(info_bytes).expect("info bytes should deserialize");
11878        assert_eq!(info.name, "test");
11879        assert_eq!(info.piece_length, 16384);
11880
11881        handle.shutdown().await.unwrap();
11882    }
11883
11884    #[tokio::test]
11885    async fn info_hash2_none_for_v1_only() {
11886        let data = vec![0xCD; 16384];
11887        let meta = make_test_torrent(&data, 16384);
11888        let storage = make_storage(&data, 16384);
11889        let config = test_config();
11890
11891        let (atx, amask) = test_alert_channel();
11892        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
11893        let handle = TorrentHandle::from_torrent(
11894            meta,
11895            irontide_core::TorrentVersion::V1Only,
11896            None,
11897            dh,
11898            dm,
11899            config,
11900            test_dht_rx(),
11901            test_dht_rx(),
11902            None,
11903            None,
11904            crate::slot_tuner::SlotTuner::disabled(4),
11905            atx,
11906            amask,
11907            None,
11908            None,
11909            test_ban_manager(),
11910            test_ip_filter(),
11911            Arc::new(Vec::new()),
11912            None,
11913            None,
11914            Arc::new(crate::transport::NetworkFactory::tokio()),
11915            None,
11916            Arc::new(crate::stats::SessionCounters::new()),
11917        )
11918        .await
11919        .unwrap();
11920
11921        tokio::time::sleep(Duration::from_millis(50)).await;
11922
11923        let rd = handle.save_resume_data().await.unwrap();
11924
11925        // v1-only torrent must not have a v2 hash
11926        assert!(
11927            rd.info_hash2.is_none(),
11928            "v1-only torrent should have info_hash2 = None"
11929        );
11930
11931        // Timestamps should be populated
11932        assert!(
11933            rd.added_time > 0,
11934            "added_time should be a positive POSIX timestamp"
11935        );
11936
11937        handle.shutdown().await.unwrap();
11938    }
11939
11940    #[tokio::test]
11941    async fn info_none_for_unresolved_magnet() {
11942        let magnet = Magnet {
11943            info_hashes: irontide_core::InfoHashes::v1_only(
11944                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
11945            ),
11946            display_name: Some("magnet-test".into()),
11947            trackers: vec![],
11948            peers: vec![],
11949            selected_files: None,
11950        };
11951        let config = test_config();
11952
11953        let (atx, amask) = test_alert_channel();
11954        let (dm, _dj) = test_disk_manager();
11955        let handle = TorrentHandle::from_magnet(
11956            magnet,
11957            dm,
11958            config,
11959            test_dht_rx(),
11960            test_dht_rx(),
11961            None,
11962            None,
11963            crate::slot_tuner::SlotTuner::disabled(4),
11964            atx,
11965            amask,
11966            None,
11967            None,
11968            test_ban_manager(),
11969            test_ip_filter(),
11970            Arc::new(Vec::new()),
11971            None,
11972            None,
11973            Arc::new(crate::transport::NetworkFactory::tokio()),
11974            None,
11975            Arc::new(crate::stats::SessionCounters::new()),
11976        )
11977        .await
11978        .unwrap();
11979
11980        tokio::time::sleep(Duration::from_millis(50)).await;
11981
11982        let rd = handle.save_resume_data().await.unwrap();
11983
11984        // Unresolved magnet has no metadata, so info must be None
11985        assert!(
11986            rd.info.is_none(),
11987            "unresolved magnet should have info = None"
11988        );
11989
11990        // added_time should still be set even for magnets
11991        assert!(
11992            rd.added_time > 0,
11993            "added_time should be set for magnet links"
11994        );
11995
11996        handle.shutdown().await.unwrap();
11997    }
11998
11999    // ---- v0.173.1: TorrentCommand::GetMeta tests (Class A architectural fix) ----
12000
12001    #[tokio::test]
12002    async fn torrent_command_get_meta_returns_none_before_metadata() {
12003        // v0.173.1: pre-metadata magnet handles must return None from GetMeta.
12004        let info_hash =
12005            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").expect("valid hex");
12006        let handle = create_magnet_handle(info_hash).await;
12007
12008        let (tx, rx) = oneshot::channel();
12009        handle
12010            .cmd_tx
12011            .send(TorrentCommand::GetMeta { reply: tx })
12012            .await
12013            .expect("cmd_tx send");
12014        let result = rx.await.expect("GetMeta reply");
12015        assert!(
12016            result.is_none(),
12017            "pre-metadata magnet must return None from GetMeta"
12018        );
12019
12020        handle.shutdown().await.unwrap();
12021    }
12022
12023    #[tokio::test]
12024    async fn torrent_command_get_meta_returns_some_after_metadata() {
12025        // v0.173.1: once metadata is assembled (via PreResolvedMetadata push),
12026        // GetMeta must return Some(meta) with the matching info hash.
12027        let (info_bytes, info_hash) = make_test_info_bytes();
12028        let handle = create_magnet_handle(info_hash).await;
12029
12030        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12031
12032        // Poll GetMeta until it returns Some or we exceed a 2s budget — the
12033        // PreResolvedMetadata command runs through the actor select! loop
12034        // asynchronously so we can't rely on a hard sleep.
12035        let mut result = None;
12036        for _ in 0..100 {
12037            tokio::time::sleep(Duration::from_millis(20)).await;
12038            let (tx, rx) = oneshot::channel();
12039            handle
12040                .cmd_tx
12041                .send(TorrentCommand::GetMeta { reply: tx })
12042                .await
12043                .expect("cmd_tx send");
12044            let r = rx.await.expect("GetMeta reply");
12045            if r.is_some() {
12046                result = r;
12047                break;
12048            }
12049        }
12050        let meta = result.expect("GetMeta must return Some after PreResolvedMetadata");
12051        assert_eq!(meta.info_hash, info_hash);
12052
12053        handle.shutdown().await.unwrap();
12054    }
12055
12056    // ── M178 Lane B1: WebSeedStats actor state machine ───────────────
12057
12058    #[tokio::test]
12059    async fn web_seed_progress_idle_to_active_on_first_success() {
12060        let mut actor = TorrentActor::for_throttle_test(8, 0);
12061        actor.handle_web_seed_progress("http://seed.example/file", 1024, 1_000_000, None);
12062        let stats = actor
12063            .web_seed_stats
12064            .get("http://seed.example/file")
12065            .expect("stats inserted");
12066        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12067        assert_eq!(stats.downloaded_bytes, 1024);
12068        assert_eq!(stats.last_rate_bps, 1_000_000);
12069        assert_eq!(stats.consecutive_failures, 0);
12070        assert!(stats.last_attempt_unix_secs > 0);
12071        assert!(actor.need_save_resume);
12072    }
12073
12074    #[tokio::test]
12075    async fn web_seed_progress_active_to_errored_then_recovery_persists_last_error() {
12076        let mut actor = TorrentActor::for_throttle_test(8, 0);
12077        let url = "http://seed.example/file".to_string();
12078
12079        // 1) Initial success → Active
12080        actor.handle_web_seed_progress(&url, 1024, 100, None);
12081        assert_eq!(
12082            actor.web_seed_stats[&url].state,
12083            irontide_core::WebSeedState::Active
12084        );
12085
12086        // 2) Failure → Errored, last_error populated
12087        actor.handle_web_seed_progress(&url, 1024, 0, Some("503".into()));
12088        let stats = &actor.web_seed_stats[&url];
12089        assert_eq!(stats.state, irontide_core::WebSeedState::Errored);
12090        assert_eq!(stats.last_error.as_deref(), Some("503"));
12091        assert_eq!(stats.consecutive_failures, 1);
12092
12093        // 3) Recovery → Active, but last_error PERSISTS (Issue 2.2)
12094        actor.handle_web_seed_progress(&url, 2048, 200, None);
12095        let stats = &actor.web_seed_stats[&url];
12096        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12097        assert_eq!(
12098            stats.last_error.as_deref(),
12099            Some("503"),
12100            "last_error must persist through recovery (D-eng-8)"
12101        );
12102        assert_eq!(
12103            stats.consecutive_failures, 0,
12104            "consecutive_failures resets on success"
12105        );
12106    }
12107
12108    #[tokio::test]
12109    async fn web_seed_progress_consecutive_failures_monotonic_within_run() {
12110        let mut actor = TorrentActor::for_throttle_test(8, 0);
12111        let url = "http://seed.example/file".to_string();
12112
12113        actor.handle_web_seed_progress(&url, 0, 0, Some("e1".into()));
12114        actor.handle_web_seed_progress(&url, 0, 0, Some("e2".into()));
12115        actor.handle_web_seed_progress(&url, 0, 0, Some("e3".into()));
12116        let stats = &actor.web_seed_stats[&url];
12117        assert_eq!(stats.consecutive_failures, 3);
12118        assert_eq!(
12119            stats.last_error.as_deref(),
12120            Some("e3"),
12121            "last_error reflects most recent message"
12122        );
12123
12124        actor.handle_web_seed_progress(&url, 1024, 100, None);
12125        assert_eq!(
12126            actor.web_seed_stats[&url].consecutive_failures, 0,
12127            "success resets consecutive_failures"
12128        );
12129    }
12130
12131    // ── M178 Lane B3: PeX + LSD peer counters ────────────────────────
12132
12133    /// Inject a `PeerStates` into a synthetic actor so `handle_add_peers`
12134    /// can run without spinning up the full peer pipeline.
12135    fn install_peer_states(actor: &mut TorrentActor) {
12136        let (queue_tx, _queue_rx) = mpsc::unbounded_channel();
12137        actor.peer_states = Some(std::sync::Arc::new(crate::peer_states::PeerStates::new(
12138            queue_tx,
12139        )));
12140    }
12141
12142    fn addr(octet: u8, port: u16) -> std::net::SocketAddr {
12143        std::net::SocketAddr::new(
12144            std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, octet)),
12145            port,
12146        )
12147    }
12148
12149    #[tokio::test]
12150    async fn pex_count_dedups_same_peer_in_two_messages() {
12151        let mut actor = TorrentActor::for_throttle_test(8, 0);
12152        install_peer_states(&mut actor);
12153
12154        // Message 1: peers A and B
12155        actor.handle_add_peers(
12156            vec![addr(1, 6881), addr(2, 6881)],
12157            crate::peer_state::PeerSource::Pex,
12158        );
12159        // Message 2: peer A again, plus C
12160        actor.handle_add_peers(
12161            vec![addr(1, 6881), addr(3, 6881)],
12162            crate::peer_state::PeerSource::Pex,
12163        );
12164        assert_eq!(
12165            actor.pex_peer_count, 3,
12166            "3 unique peers across 2 PEX messages, A counted once"
12167        );
12168        assert_eq!(actor.lsd_peer_count, 0, "LSD untouched");
12169    }
12170
12171    #[tokio::test]
12172    async fn lsd_count_aggregates_across_multicasts() {
12173        let mut actor = TorrentActor::for_throttle_test(8, 0);
12174        install_peer_states(&mut actor);
12175
12176        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Lsd);
12177        actor.handle_add_peers(
12178            vec![addr(2, 6881), addr(3, 6881)],
12179            crate::peer_state::PeerSource::Lsd,
12180        );
12181        actor.handle_add_peers(
12182            vec![addr(1, 6881)], // dup
12183            crate::peer_state::PeerSource::Lsd,
12184        );
12185        assert_eq!(actor.lsd_peer_count, 3);
12186    }
12187
12188    #[tokio::test]
12189    async fn other_sources_do_not_bump_pex_or_lsd() {
12190        let mut actor = TorrentActor::for_throttle_test(8, 0);
12191        install_peer_states(&mut actor);
12192
12193        actor.handle_add_peers(
12194            vec![addr(1, 6881), addr(2, 6881)],
12195            crate::peer_state::PeerSource::Tracker,
12196        );
12197        actor.handle_add_peers(vec![addr(3, 6881)], crate::peer_state::PeerSource::Dht);
12198        actor.handle_add_peers(vec![addr(4, 6881)], crate::peer_state::PeerSource::Incoming);
12199        assert_eq!(actor.pex_peer_count, 0);
12200        assert_eq!(actor.lsd_peer_count, 0);
12201    }
12202
12203    #[tokio::test]
12204    async fn dedup_runs_against_global_seen_set() {
12205        // A peer first observed via tracker won't recount when later
12206        // re-announced via PEX, because the seen-set is shared across
12207        // sources. This is the intended behaviour: PEX/LSD counts measure
12208        // *new* peer discoveries from those subsystems, not redundant
12209        // re-announcements.
12210        let mut actor = TorrentActor::for_throttle_test(8, 0);
12211        install_peer_states(&mut actor);
12212
12213        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Tracker);
12214        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Pex);
12215        assert_eq!(
12216            actor.pex_peer_count, 0,
12217            "peer already seen via tracker — PEX shouldn't re-count"
12218        );
12219    }
12220
12221    #[tokio::test]
12222    async fn web_seed_progress_dirties_resume_flag() {
12223        let mut actor = TorrentActor::for_throttle_test(8, 0);
12224        actor.need_save_resume = false;
12225        actor.handle_web_seed_progress("http://x/file", 100, 50, None);
12226        assert!(
12227            actor.need_save_resume,
12228            "every progress event should mark fast-resume dirty"
12229        );
12230    }
12231
12232    #[tokio::test]
12233    async fn paused_torrent_rejects_outbound_peer_connect() {
12234        let mut actor = TorrentActor::for_throttle_test(8, 0);
12235        install_peer_states(&mut actor);
12236        actor.state = TorrentState::Paused;
12237
12238        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12239        let permit = sem.clone().acquire_owned().await.unwrap();
12240        let connect = crate::peer_adder::ConnectPeer {
12241            addr: addr(1, 6881),
12242            source: crate::peer_state::PeerSource::Dht,
12243            permit,
12244        };
12245        actor.handle_adder_connect(connect);
12246        assert!(
12247            actor.peers.is_empty(),
12248            "paused torrent must not accept outbound peer connections"
12249        );
12250        assert_eq!(
12251            sem.available_permits(),
12252            1,
12253            "semaphore permit must be released on rejection"
12254        );
12255    }
12256
12257    #[tokio::test]
12258    async fn resume_from_queued_restores_fetching_metadata_for_magnets() {
12259        let mut actor = TorrentActor::for_throttle_test(0, 0);
12260        actor.state = TorrentState::Queued;
12261        assert!(
12262            actor.chunk_tracker.is_none(),
12263            "magnet torrent has no chunk tracker before metadata"
12264        );
12265        assert_eq!(actor.num_pieces, 0);
12266
12267        actor.handle_resume().await;
12268        assert_eq!(
12269            actor.state,
12270            TorrentState::FetchingMetadata,
12271            "magnet torrent must resume to FetchingMetadata, not Downloading"
12272        );
12273    }
12274
12275    #[tokio::test]
12276    async fn resume_from_queued_restores_downloading_when_metadata_known() {
12277        let mut actor = TorrentActor::for_throttle_test(8, 0);
12278        actor.state = TorrentState::Queued;
12279
12280        actor.handle_resume().await;
12281        assert_eq!(
12282            actor.state,
12283            TorrentState::Downloading,
12284            "torrent with known pieces must resume to Downloading"
12285        );
12286    }
12287
12288    #[tokio::test]
12289    async fn queued_torrent_rejects_outbound_peer_connect() {
12290        let mut actor = TorrentActor::for_throttle_test(8, 0);
12291        install_peer_states(&mut actor);
12292        actor.state = TorrentState::Queued;
12293
12294        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12295        let permit = sem.clone().acquire_owned().await.unwrap();
12296        let connect = crate::peer_adder::ConnectPeer {
12297            addr: addr(1, 6881),
12298            source: crate::peer_state::PeerSource::Dht,
12299            permit,
12300        };
12301        actor.handle_adder_connect(connect);
12302        assert!(
12303            actor.peers.is_empty(),
12304            "queued torrent must not accept outbound peer connections"
12305        );
12306        assert_eq!(
12307            sem.available_permits(),
12308            1,
12309            "semaphore permit must be released on rejection"
12310        );
12311    }
12312
12313    /// Inject a synthetic `PeerState` directly into `actor.peers` so
12314    /// `disconnect_peer` exercises the flush path without spinning up
12315    /// real peer tasks.
12316    fn inject_peer_for_flush(
12317        actor: &mut TorrentActor,
12318        peer_addr: std::net::SocketAddr,
12319        unchoke_started: Option<std::time::Instant>,
12320        prior_total: std::time::Duration,
12321    ) {
12322        let (cmd_tx, _cmd_rx) = mpsc::channel(8);
12323        let mut peer = crate::peer_state::PeerState::new(
12324            peer_addr,
12325            actor.num_pieces,
12326            cmd_tx,
12327            crate::peer_state::PeerSource::Tracker,
12328            Arc::new(AtomicU32::new(0)),
12329            Arc::new(AtomicU32::new(128)),
12330            Arc::new(tokio::sync::Notify::new()),
12331        );
12332        peer.am_unchoke_started_at = unchoke_started;
12333        peer.unchoke_duration_total = prior_total;
12334        actor.peers.insert(peer_addr, peer);
12335    }
12336
12337    #[tokio::test]
12338    async fn disconnect_while_unchoked_flushes_delta_into_torrent_map() {
12339        let mut actor = TorrentActor::for_throttle_test(8, 0);
12340        let p = addr(1, 6881);
12341
12342        // Seed the peer with an in-flight unchoke window opened ~50 ms ago
12343        // and a pre-existing 100 ms accumulator from prior toggles.
12344        inject_peer_for_flush(
12345            &mut actor,
12346            p,
12347            Some(std::time::Instant::now() - std::time::Duration::from_millis(50)),
12348            std::time::Duration::from_millis(100),
12349        );
12350
12351        actor.disconnect_peer(p, "test");
12352
12353        let total = actor
12354            .unchoke_durations
12355            .get(&p)
12356            .copied()
12357            .expect("disconnect must flush a non-zero delta into the torrent map");
12358        assert!(
12359            total >= std::time::Duration::from_millis(140),
12360            "expected ≥140 ms (100 prior + ~50 in-flight), got {total:?}"
12361        );
12362    }
12363
12364    #[tokio::test]
12365    async fn disconnect_then_reconnect_preserves_history() {
12366        let mut actor = TorrentActor::for_throttle_test(8, 0);
12367        let p = addr(2, 6881);
12368
12369        // First connection: 80 ms unchoke window already accumulated.
12370        inject_peer_for_flush(&mut actor, p, None, std::time::Duration::from_millis(80));
12371        actor.disconnect_peer(p, "test");
12372        let after_first = *actor
12373            .unchoke_durations
12374            .get(&p)
12375            .expect("first flush must populate the entry");
12376        assert_eq!(after_first, std::time::Duration::from_millis(80));
12377
12378        // Reconnect: peer rejoins with a fresh in-flight window.
12379        inject_peer_for_flush(
12380            &mut actor,
12381            p,
12382            Some(std::time::Instant::now() - std::time::Duration::from_millis(40)),
12383            std::time::Duration::ZERO,
12384        );
12385        actor.disconnect_peer(p, "test");
12386        let after_second = *actor.unchoke_durations.get(&p).unwrap();
12387        assert!(
12388            after_second >= std::time::Duration::from_millis(120),
12389            "second flush must add to the existing entry, got {after_second:?}"
12390        );
12391    }
12392
12393    // -- M187 Fix B: piece-verified wakes reservation_notify --
12394
12395    #[tokio::test]
12396    async fn piece_verified_wakes_reservation_notify() {
12397        let mut actor = TorrentActor::for_throttle_test(8, 0);
12398        let notify = Arc::new(tokio::sync::Notify::new());
12399        actor.reservation_notify = Some(Arc::clone(&notify));
12400
12401        let notified = notify.notified();
12402        tokio::pin!(notified);
12403        assert!(
12404            futures::poll!(&mut notified).is_pending(),
12405            "notify should not have fired yet"
12406        );
12407
12408        actor.on_piece_verified(0).await;
12409
12410        tokio::time::timeout(Duration::from_secs(1), notified)
12411            .await
12412            .expect("reservation_notify must be woken by on_piece_verified");
12413    }
12414
12415    // -- 2026-05-11 state-gated pipeline-tick safety-net wake --
12416
12417    /// Helper: construct an actor that already has a `PieceTracker` with the
12418    /// given (`queue_count`, `inflight_count`). The tracker starts empty and
12419    /// we mark pieces wanted/reserved as needed to land on the target shape.
12420    fn actor_with_tracker_state(queue: u32, inflight: u32) -> TorrentActor {
12421        use crate::piece_reservation::PieceTracker;
12422        use irontide_storage::Bitfield;
12423        let mut actor = TorrentActor::for_throttle_test(8, 0);
12424        let num_pieces = queue + inflight + 1;
12425        let we_have = Bitfield::new(num_pieces);
12426        let mut wanted = Bitfield::new(num_pieces);
12427        for i in 0..num_pieces {
12428            wanted.set(i);
12429        }
12430        let mut pt = PieceTracker::new(num_pieces, &we_have, &wanted);
12431        // Trim the queue down to `queue` (the rest become "completed" by
12432        // marking them unwanted, which clears them from queue_pieces).
12433        for i in queue..num_pieces {
12434            pt.mark_unwanted(i);
12435        }
12436        // Move `inflight` pieces from queue to inflight via record_reservation.
12437        for i in 0..inflight {
12438            pt.record_reservation(i, "10.0.0.1:6881".parse().unwrap());
12439        }
12440        // After this: queue_count() == queue - inflight, inflight_count() == inflight.
12441        // We started with `queue` wanted pieces, then reserved `inflight` of
12442        // them, leaving (queue - inflight) in the queue. Adjust caller-facing
12443        // semantics so the helper's name matches the assertion.
12444        actor.piece_tracker = Some(pt);
12445        actor
12446    }
12447
12448    #[tokio::test]
12449    async fn pipeline_tick_skips_wake_when_dispatch_state_unchanged() {
12450        let mut actor = actor_with_tracker_state(10, 3);
12451        let notify = Arc::new(tokio::sync::Notify::new());
12452        actor.reservation_notify = Some(Arc::clone(&notify));
12453
12454        // First tick seeds the baseline and always wakes — this matches the
12455        // helper's documented first-call semantics. Drop the baseline wake
12456        // by polling once before installing the real test waiter.
12457        actor.tick_dispatch_safety_wake();
12458        let _drain = notify.notified();
12459
12460        // No dispatch state change between this tick and the next.
12461        let notified = notify.notified();
12462        tokio::pin!(notified);
12463        actor.tick_dispatch_safety_wake();
12464
12465        // Give tokio a chance to dispatch any pending wakes before asserting.
12466        tokio::task::yield_now().await;
12467        assert!(
12468            futures::poll!(&mut notified).is_pending(),
12469            "tick must not wake when (queue_count, inflight_count) is unchanged"
12470        );
12471        // And the skip counter increments.
12472        let skipped = actor.counters.get(crate::stats::DISPATCH_TICK_WAKE_SKIPPED);
12473        assert!(
12474            skipped >= 1,
12475            "expected DISPATCH_TICK_WAKE_SKIPPED >= 1, got {skipped}"
12476        );
12477    }
12478
12479    #[tokio::test]
12480    async fn pipeline_tick_wakes_when_inflight_changes() {
12481        let mut actor = actor_with_tracker_state(10, 3);
12482        let notify = Arc::new(tokio::sync::Notify::new());
12483        actor.reservation_notify = Some(Arc::clone(&notify));
12484
12485        // Seed baseline.
12486        actor.tick_dispatch_safety_wake();
12487
12488        // Mutate dispatch state: reserve another piece via the tracker. This
12489        // changes both queue_count (down 1) and inflight_count (up 1).
12490        if let Some(ref mut pt) = actor.piece_tracker {
12491            pt.record_reservation(5, "10.0.0.2:6881".parse().unwrap());
12492        }
12493
12494        let notified = notify.notified();
12495        tokio::pin!(notified);
12496        actor.tick_dispatch_safety_wake();
12497
12498        tokio::time::timeout(Duration::from_secs(1), notified)
12499            .await
12500            .expect("tick must wake when dispatch state changed");
12501    }
12502}