Skip to main content

irontide_engine/
torrent.rs

1// M175: TorrentActor handles piece arithmetic (bounded by num_pieces: u32),
2// peer counters (bounded by max_peers_per_torrent), time deltas vs Instants
3// captured during the actor's lifetime, and qBt DTO conversions where the
4// wire format dictates narrower integer types. Per-site rationale carried
5// forward from M175 plan; review at a single grep target via
6// `grep "M175" crates/irontide-session/src/torrent.rs`.
7#![allow(
8    clippy::cast_possible_truncation,
9    clippy::cast_precision_loss,
10    clippy::cast_possible_wrap,
11    clippy::cast_sign_loss,
12    clippy::unchecked_time_subtraction,
13    reason = "M175: piece/peer arithmetic bounded by num_pieces/max_peers (u32); time deltas use post-init Instants; qBt DTOs follow wire-format integer widths"
14)]
15
16//! `TorrentActor` (single-owner event loop) and `TorrentHandle` (cloneable public API).
17//!
18//! The actor owns all per-torrent state (chunk tracking, piece selection, choking,
19//! peer management) and communicates with spawned `PeerTasks` via channels.
20//! The handle is a thin wrapper around an mpsc sender.
21
22use std::collections::{BTreeSet, HashMap, HashSet};
23use std::net::SocketAddr;
24
25use rustc_hash::FxHashMap;
26use std::sync::Arc;
27use std::sync::atomic::AtomicU32;
28use std::time::{Duration, Instant};
29
30use bytes::Bytes;
31use tokio::sync::{broadcast, mpsc, oneshot};
32use tracing::{Instrument, debug, info, trace, warn};
33
34use crate::alert::{Alert, AlertKind, post_alert};
35use crate::disk::{DiskHandle, DiskJobFlags, DiskManagerHandle};
36use crate::piece_reservation::{
37    AtomicPieceStates, BlockMaps, PieceOrderMap, PieceTracker, StealCandidates,
38};
39
40use irontide_core::{
41    DEFAULT_CHUNK_SIZE, FilePriority, Id20, Lengths, Magnet, PeerId, TorrentMetaV1,
42    torrent_from_bytes,
43};
44// M173 Lane B (B6): DhtHandle is no longer imported here — TorrentActor
45// uses irontide_dht::DhtReceiver via the DhtBroadcast pattern instead.
46// `current_dht()`/`current_dht_v6()` helpers return Option<DhtHandle>
47// from the receiver and use the fully-qualified path.
48use irontide_storage::{Bitfield, ChunkTracker, MemoryStorage, TorrentStorage};
49
50use crate::choker::{Choker, PeerInfo as ChokerPeerInfo};
51use crate::end_game::EndGame;
52use crate::metadata::MetadataDownloader;
53use crate::peer_adder::{self, ConnectPeer};
54use crate::peer_state::{PeerSource, PeerState};
55use crate::tracker_manager::TrackerManager;
56use crate::types::{
57    PartialPieceInfo, PeerCommand, PeerEvent, PeerInfo, TorrentCommand, TorrentConfig,
58    TorrentState, TorrentStats,
59};
60
61/// Shared global rate limiter bucket.
62pub(crate) type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
63
64/// Tribool result for piece hash verification in hybrid torrents.
65///
66/// Mirrors libtorrent-rasterbar's `boost::tribool` approach for dual-hash
67/// verification. `NotApplicable` covers cases where verification cannot run
68/// (e.g. missing hash picker, disk error before any block is checked).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(crate) enum HashResult {
71    /// All hashes matched.
72    Passed,
73    /// At least one hash did not match.
74    Failed,
75    /// Verification could not be performed (missing state / deferred).
76    NotApplicable,
77}
78
79/// Relocate torrent files from `src_base` to `dst_base`.
80///
81/// For each file, tries `rename` first (fast, same-filesystem), then falls
82/// back to copy + delete (cross-filesystem). Creates parent directories as
83/// needed. Returns error on the first failure.
84pub(crate) fn relocate_files(
85    src_base: &std::path::Path,
86    dst_base: &std::path::Path,
87    file_paths: &[std::path::PathBuf],
88) -> std::io::Result<()> {
89    for rel_path in file_paths {
90        let src = src_base.join(rel_path);
91        let dst = dst_base.join(rel_path);
92
93        if !src.exists() {
94            // File may not exist yet (e.g., not downloaded)
95            continue;
96        }
97
98        if let Some(parent) = dst.parent() {
99            std::fs::create_dir_all(parent)?;
100        }
101
102        // Try rename first (O(1) on same filesystem)
103        if std::fs::rename(&src, &dst).is_err() {
104            // Cross-filesystem: copy + delete
105            std::fs::copy(&src, &dst)?;
106            std::fs::remove_file(&src)?;
107        }
108    }
109
110    // Try to remove empty parent directories from source
111    // (best-effort, ignore errors)
112    for rel_path in file_paths {
113        let mut dir = src_base.join(rel_path);
114        dir.pop(); // get parent dir
115        while dir != *src_base {
116            if std::fs::remove_dir(&dir).is_err() {
117                break; // not empty or other error
118            }
119            dir.pop();
120        }
121    }
122
123    Ok(())
124}
125
126/// M224: Initial regular-unchoke slot count for a fresh `Choker`. Returns
127/// the cap from `Settings.max_uploads_per_torrent` when `n >= 1`, or the
128/// historical default of 4 when `n == -1` ("unlimited").
129pub(crate) fn initial_unchoke_slots(max_uploads_per_torrent: i32) -> usize {
130    if max_uploads_per_torrent >= 1 {
131        max_uploads_per_torrent as usize
132    } else {
133        4
134    }
135}
136
137/// Current time as POSIX seconds (0 on clock error).
138pub(crate) fn now_unix() -> i64 {
139    std::time::SystemTime::now()
140        .duration_since(std::time::UNIX_EPOCH)
141        .map_or(0, |d| d.as_secs() as i64)
142}
143
144/// M112: Cooldown period between holepunch attempts to the same address.
145pub(crate) const HOLEPUNCH_COOLDOWN: Duration = Duration::from_mins(2);
146
147/// M112: Maximum number of tracked holepunch cooldown entries to prevent unbounded growth.
148pub(crate) const HOLEPUNCH_MAX_TRACKED: usize = 256;
149
150/// M190: Maximum rendezvous requests per peer within the relay rate window.
151pub(crate) const HOLEPUNCH_RELAY_MAX_PER_WINDOW: u32 = 5;
152
153/// M190: Duration of the relay rate-limit window.
154pub(crate) const HOLEPUNCH_RELAY_WINDOW: Duration = Duration::from_secs(30);
155
156/// Returns true if the disconnect reason suggests the peer is behind NAT
157/// and a holepunch attempt might succeed.
158pub(crate) fn should_attempt_holepunch(reason: &str) -> bool {
159    // Don't re-attempt holepunch for failures from a previous holepunch attempt
160    if reason.contains("holepunch") {
161        return false;
162    }
163    reason.contains("refused")
164        || reason.contains("timed out")
165        || reason.contains("Connection reset")
166        || reason.contains("connection reset")
167}
168
169/// Cloneable handle for interacting with a running torrent.
170#[derive(Clone)]
171pub struct TorrentHandle {
172    pub cmd_tx: mpsc::Sender<TorrentCommand>,
173}
174
175impl TorrentHandle {
176    /// Create a torrent session from parsed .torrent metadata.
177    ///
178    /// Spawns the actor event loop and returns a handle for sending commands.
179    ///
180    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
181    /// from the session-level [`irontide_dht::DhtBroadcast`]. They observe
182    /// runtime DHT restarts (B11) so the torrent never holds a stale
183    /// `DhtHandle` clone whose backing actor has shut down.
184    ///
185    /// # Errors
186    /// Returns an error if disk registration fails or the torrent actor cannot be started.
187    #[allow(clippy::too_many_arguments)]
188    pub async fn from_torrent(
189        meta: TorrentMetaV1,
190        version: irontide_core::TorrentVersion,
191        meta_v2: Option<irontide_core::TorrentMetaV2>,
192        disk: DiskHandle,
193        disk_manager: DiskManagerHandle,
194        config: TorrentConfig,
195        dht_rx: irontide_dht::DhtReceiver,
196        dht_v6_rx: irontide_dht::DhtReceiver,
197        global_upload_bucket: Option<SharedBucket>,
198        global_download_bucket: Option<SharedBucket>,
199        slot_tuner: crate::slot_tuner::SlotTuner,
200        alert_tx: broadcast::Sender<Alert>,
201        alert_mask: Arc<AtomicU32>,
202        utp_socket: Option<irontide_utp::UtpSocket>,
203        utp_socket_v6: Option<irontide_utp::UtpSocket>,
204        ban_manager: irontide_session_types::SharedBanManager,
205        ip_filter: irontide_session_types::SharedIpFilter,
206        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
207        sam_session: Option<Arc<crate::i2p::SamSession>>,
208        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
209        factory: Arc<crate::transport::NetworkFactory>,
210        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
211        counters: Arc<crate::stats::SessionCounters>,
212    ) -> crate::Result<Self> {
213        let mut config = config;
214        // BEP 27: private torrents disable DHT, PEX, and LSD
215        if meta.info.private == Some(1) {
216            config.enable_dht = false;
217            config.enable_pex = false;
218            config.enable_lsd = false;
219        }
220
221        let info_hashes = match (&version, &meta_v2) {
222            (irontide_core::TorrentVersion::Hybrid, Some(v2_meta)) => {
223                if let Some(v2_hash) = v2_meta.info_hashes.v2 {
224                    irontide_core::InfoHashes::hybrid(meta.info_hash, v2_hash)
225                } else {
226                    irontide_core::InfoHashes::v1_only(meta.info_hash)
227                }
228            }
229            (irontide_core::TorrentVersion::V2Only, Some(v2_meta)) => v2_meta.info_hashes.clone(),
230            _ => irontide_core::InfoHashes::v1_only(meta.info_hash),
231        };
232
233        if meta.info.piece_length > config.max_piece_length {
234            return Err(crate::Error::InvalidSettings(format!(
235                "piece_length {} exceeds max_piece_length {}",
236                meta.info.piece_length, config.max_piece_length
237            )));
238        }
239
240        let num_pieces = meta.info.num_pieces() as u32;
241        let lengths = Lengths::new(
242            meta.info.total_length(),
243            meta.info.piece_length,
244            DEFAULT_CHUNK_SIZE,
245        );
246        let mut chunk_tracker = ChunkTracker::new(lengths.clone());
247
248        // Initialize HashPicker for v2/hybrid torrents and enable v2 block tracking
249        let hash_picker = if version.has_v2() {
250            if let Some(ref v2_meta) = meta_v2 {
251                chunk_tracker.enable_v2_tracking();
252
253                let block_size = 16384u64;
254                let blocks_per_piece = (meta.info.piece_length / block_size) as u32;
255
256                // Build FileHashInfo from v2 file tree
257                let v2_files = v2_meta.info.files();
258                let file_infos: Vec<irontide_core::FileHashInfo> = v2_files
259                    .iter()
260                    .filter_map(|f| {
261                        let root = f.attr.pieces_root?;
262                        let num_blocks = f.attr.length.div_ceil(block_size) as u32;
263                        let num_pieces = f.attr.length.div_ceil(meta.info.piece_length) as u32;
264                        Some(irontide_core::FileHashInfo {
265                            root,
266                            num_blocks,
267                            num_pieces,
268                        })
269                    })
270                    .collect();
271
272                if file_infos.is_empty() {
273                    None
274                } else {
275                    let mut picker = irontide_core::HashPicker::new(&file_infos, blocks_per_piece);
276
277                    // Pre-load piece-layer hashes from the .torrent file
278                    let _verified = picker.load_piece_layers(&v2_meta.piece_layers);
279
280                    Some(picker)
281                }
282            } else {
283                None
284            }
285        } else {
286            None
287        };
288
289        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
290        let file_priorities = vec![FilePriority::Normal; file_lengths.len()];
291        let wanted_pieces =
292            crate::piece_selector::build_wanted_pieces(&file_priorities, &file_lengths, &lengths);
293
294        let (cmd_tx, cmd_rx) = mpsc::channel(256);
295        let (event_tx, event_rx) = mpsc::channel(2048);
296        let (write_error_tx, write_error_rx) = mpsc::channel(64);
297        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
298        let (hash_result_tx, hash_result_rx) = mpsc::channel(64); // M96
299        let our_peer_id = if config.anonymous_mode {
300            PeerId::generate_anonymous().0
301        } else {
302            PeerId::generate().0
303        };
304
305        // Bind listener for incoming connections
306        // Try dual-stack [::]:port first, fall back to IPv4-only
307        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
308            .bind_tcp(SocketAddr::from((
309                std::net::Ipv6Addr::UNSPECIFIED,
310                config.listen_port,
311            )))
312            .await
313        {
314            Ok(l) => Some(l),
315            Err(_) => factory
316                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
317                .await
318                .ok(),
319        };
320        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
321
322        let mut tracker_manager = TrackerManager::from_torrent_filtered(
323            &meta,
324            our_peer_id,
325            config.listen_port,
326            config.url_security,
327            config.peer_dscp,
328            config.anonymous_mode,
329        );
330        tracker_manager.set_info_hashes(info_hashes.clone());
331
332        // BEP 7: include our I2P destination in tracker announces
333        if let Some(ref sam) = sam_session {
334            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
335        }
336
337        let enable_dht = config.enable_dht;
338
339        // M173 Lane B (B6): snapshot the broadcast at construction time
340        // to seed the initial peer-discovery channels. Future DHT
341        // restarts (B11) deliver peer batches via the watch
342        // subscription, not the initial snapshot.
343        let dht_initial = dht_rx.current();
344        let dht_v6_initial = dht_v6_rx.current();
345
346        // Start DHT peer discovery if enabled and available
347        let dht_peers_rx = if enable_dht {
348            if let Some(ref dht) = dht_initial {
349                match dht.get_peers(meta.info_hash).await {
350                    Ok(rx) => Some(rx),
351                    Err(e) => {
352                        warn!("failed to start DHT v4 get_peers: {e}");
353                        None
354                    }
355                }
356            } else {
357                None
358            }
359        } else {
360            None
361        };
362
363        let dht_v6_peers_rx = if enable_dht {
364            if let Some(ref dht6) = dht_v6_initial {
365                match dht6.get_peers(meta.info_hash).await {
366                    Ok(rx) => Some(rx),
367                    Err(e) => {
368                        debug!("failed to start DHT v6 get_peers: {e}");
369                        None
370                    }
371                }
372            } else {
373                None
374            }
375        } else {
376            None
377        };
378
379        // Dual-swarm: also search for v2 hash peers if hybrid
380        let v2_as_v1 = if info_hashes.is_hybrid() {
381            info_hashes
382                .v2
383                .map(|v2| Id20(v2.0[..20].try_into().unwrap()))
384        } else {
385            None
386        };
387        let (dht_v2_peers_rx, dht_v6_v2_peers_rx) =
388            if let (true, Some(v2_id)) = (enable_dht, v2_as_v1) {
389                let rx4 = if let Some(ref dht) = dht_initial {
390                    dht.get_peers(v2_id).await.ok()
391                } else {
392                    None
393                };
394                let rx6 = if let Some(ref dht6) = dht_v6_initial {
395                    dht6.get_peers(v2_id).await.ok()
396                } else {
397                    None
398                };
399                (rx4, rx6)
400            } else {
401                (None, None)
402            };
403
404        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
405        let download_bucket = Arc::new(parking_lot::Mutex::new(
406            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
407        ));
408        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
409            0,
410            0,
411            0,
412            0,
413            config.upload_rate_limit,
414            config.download_rate_limit,
415        );
416
417        let super_seed = if config.super_seeding {
418            Some(crate::super_seed::SuperSeedState::new())
419        } else {
420            None
421        };
422        // M118: broadcast channel for Have distribution — capacity scales with torrent size
423        let (have_broadcast_tx, _) =
424            tokio::sync::broadcast::channel(std::cmp::max(128, num_pieces as usize / 4));
425        let is_share_mode = config.share_mode;
426
427        let (piece_ready_tx, _) = broadcast::channel(64);
428        let initial_have = chunk_tracker.bitfield().clone();
429        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(initial_have);
430        let stream_read_semaphore =
431            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
432
433        let choker = Choker::with_algorithms(
434            initial_unchoke_slots(config.max_uploads_per_torrent),
435            config.seed_choking_algorithm,
436            config.choking_algorithm,
437            config.upload_rate_limit,
438            2,
439            20,
440        );
441
442        // M96: Wire hash pool into disk handle for V1-only torrents
443        let mut disk = disk;
444        if matches!(version, irontide_core::TorrentVersion::V1Only)
445            && let Some(pool) = &hash_pool
446        {
447            disk.set_hash_pool(pool.clone());
448            disk.set_hash_result_tx(hash_result_tx.clone());
449        }
450
451        // M116: Pre-compute file->piece mapping for zero-alloc completion checks.
452        let cached_files = Some(build_cached_file_info(&meta, &lengths));
453
454        // v0.173.4 (prong 1): seed the snapshot watch with an empty initial
455        let (order_map_tx, _order_map_rx_seed) =
456            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
457
458        let actor = TorrentActor {
459            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
460                config.lock_warn_threshold_ms,
461            ),
462            config,
463            info_hash: meta.info_hash,
464            our_peer_id,
465            state: TorrentState::Downloading,
466            disk: Some(disk),
467            disk_manager,
468            chunk_tracker: Some(chunk_tracker),
469            lengths: Some(lengths),
470            num_pieces,
471            streaming_pieces: BTreeSet::new(),
472            time_critical_pieces: BTreeSet::new(),
473            streaming_cursors: Vec::new(),
474            piece_ready_tx,
475            have_watch_tx,
476            have_watch_rx,
477            stream_read_semaphore,
478            file_priorities,
479            wanted_pieces,
480            end_game: EndGame::new(),
481            peers: HashMap::new(),
482            unchoke_durations: HashMap::new(),
483            cached_peer_rates: FxHashMap::default(),
484            refill_notify: Arc::new(tokio::sync::Notify::new()),
485            atomic_states: None,
486            block_maps: None,
487            steal_candidates: None,
488            last_steal_populate: Instant::now(),
489            piece_write_guards: None,
490            soft_reap_buf: Vec::new(),
491            eviction_history: std::collections::VecDeque::new(),
492            force_immediate_choker_tick: false,
493            piece_tracker: None,
494            order_map_dirty: false,
495            next_order_map_gen: 0,
496            order_map_tx,
497            piece_owner: Vec::new(),
498            peer_slab: crate::piece_reservation::PeerSlab::new(),
499            priority_pieces: BTreeSet::new(),
500            max_in_flight: 512,
501            reservation_notify: None,
502            last_tick_dispatch_state: None,
503            choker,
504            user_seed_mode: false,
505            user_forced: false,
506            max_connections: 0,
507            peer_states: None,
508            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
509            connect_permits: HashMap::new(),
510            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
511                std::collections::HashMap::new(),
512            )),
513            connect_rx: None,
514            metadata_downloader: None,
515            downloaded: 0,
516            uploaded: 0,
517            checking_progress: 0.0,
518            total_download: 0,
519            total_upload: 0,
520            total_failed_bytes: 0,
521            total_redundant_bytes: 0,
522            added_time: std::time::SystemTime::now()
523                .duration_since(std::time::UNIX_EPOCH)
524                .map_or(0, |d| d.as_secs() as i64),
525            completed_time: 0,
526            last_download: 0,
527            last_upload: 0,
528            last_seen_complete: 0,
529            active_duration: 0,
530            finished_duration: 0,
531            seeding_duration: 0,
532            active_since: Some(std::time::Instant::now()),
533            state_duration_since: None,
534            started_at: std::time::Instant::now(),
535            moving_storage: false,
536            has_incoming: false,
537            need_save_resume: false,
538            error: String::new(),
539            error_file: -1,
540            cmd_rx,
541            event_tx,
542            event_rx,
543            write_error_rx,
544            write_error_tx,
545            verify_result_rx,
546            verify_result_tx,
547            pending_verify: HashSet::new(),
548            piece_generations: vec![0u64; num_pieces as usize],
549            hash_result_rx,
550            hash_result_tx,
551            meta: Some(meta),
552            cached_files,
553            listener,
554            utp_socket,
555            utp_socket_v6,
556            tracker_manager,
557            tracker_result_rx: None,
558            dht_rx,
559            dht_v6_rx,
560            dht_enabled: enable_dht,
561            dht_peers_rx,
562            dht_v6_peers_rx,
563            dht_v6_empty_count: 0,
564            dht_v6_last_retry: None,
565            alert_tx,
566            alert_mask,
567            upload_bucket,
568            download_bucket,
569            global_upload_bucket,
570            global_download_bucket,
571            slot_tuner,
572            upload_bytes_interval: 0,
573            peak_download_rate: 0,
574            web_seeds: HashMap::new(),
575            banned_web_seeds: HashSet::new(),
576            web_seed_in_flight: HashMap::new(),
577            web_seed_stats: HashMap::new(),
578            pex_peer_count: 0,
579            lsd_peer_count: 0,
580            super_seed,
581            have_broadcast_tx,
582            suggested_to_peers: HashMap::new(),
583            predictive_have_sent: HashSet::new(),
584
585            ban_manager,
586            ip_filter,
587            piece_contributors: HashMap::new(),
588            parole_pieces: HashMap::new(),
589            external_ip: None,
590            share_lru: std::collections::VecDeque::new(),
591            share_max_pieces: if is_share_mode { 64 } else { 0 },
592            plugins,
593            hash_picker,
594            version,
595            meta_v2,
596            info_hashes,
597            dht_v2_peers_rx,
598            dht_v6_v2_peers_rx,
599            magnet_selected_files: None,
600            sam_session,
601            i2p_accept_rx: None,
602            i2p_peer_counter: 0,
603            i2p_destinations: HashMap::new(),
604            ssl_manager,
605            rate_limiter_set,
606            auto_sequential_active: false,
607            factory,
608            hash_pool_ref: hash_pool,
609            connect_attempts: 0,
610            connect_failures: 0,
611            choke_rotations: 0,
612            inflight_started: Vec::new(),
613            completed_piece_times: std::collections::VecDeque::new(),
614            piece_steals: 0,
615            holepunch_relayed: 0,
616            holepunch_relay_rate: HashMap::new(),
617            holepunch_cooldowns: HashMap::new(),
618            holepunch_pending: Vec::new(),
619            counters,
620        };
621
622        let spawn_info_hash = actor.info_hash;
623        let join_handle = tokio::spawn(actor.run());
624        // Monitor the actor task so panics/exits are logged instead of silently swallowed.
625        tokio::spawn(async move {
626            match join_handle.await {
627                Ok(()) => {
628                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
629                }
630                Err(e) if e.is_panic() => {
631                    let panic_payload = e.into_panic();
632                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
633                        (*s).to_string()
634                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
635                        s.clone()
636                    } else {
637                        "unknown panic payload".to_string()
638                    };
639                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
640                }
641                Err(e) => {
642                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
643                }
644            }
645        });
646        Ok(Self { cmd_tx })
647    }
648
649    /// Create a torrent session from a magnet link (metadata fetched via BEP 9).
650    ///
651    /// M173 Lane B (B6): the `dht_rx`/`dht_v6_rx` parameters are receivers
652    /// from the session-level [`irontide_dht::DhtBroadcast`]; see
653    /// [`Self::from_torrent`] for the rationale.
654    ///
655    /// # Errors
656    /// Returns an error if disk registration fails or the torrent actor cannot be started.
657    #[allow(clippy::too_many_arguments)]
658    pub async fn from_magnet(
659        magnet: Magnet,
660        disk_manager: DiskManagerHandle,
661        config: TorrentConfig,
662        dht_rx: irontide_dht::DhtReceiver,
663        dht_v6_rx: irontide_dht::DhtReceiver,
664        global_upload_bucket: Option<SharedBucket>,
665        global_download_bucket: Option<SharedBucket>,
666        slot_tuner: crate::slot_tuner::SlotTuner,
667        alert_tx: broadcast::Sender<Alert>,
668        alert_mask: Arc<AtomicU32>,
669        utp_socket: Option<irontide_utp::UtpSocket>,
670        utp_socket_v6: Option<irontide_utp::UtpSocket>,
671        ban_manager: irontide_session_types::SharedBanManager,
672        ip_filter: irontide_session_types::SharedIpFilter,
673        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
674        sam_session: Option<Arc<crate::i2p::SamSession>>,
675        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
676        factory: Arc<crate::transport::NetworkFactory>,
677        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
678        counters: Arc<crate::stats::SessionCounters>,
679    ) -> crate::Result<Self> {
680        let (cmd_tx, cmd_rx) = mpsc::channel(256);
681        let (event_tx, event_rx) = mpsc::channel(2048);
682        let (write_error_tx, write_error_rx) = mpsc::channel(64);
683        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
684        // M96: Dummy channel — replaced when metadata arrives and num_pieces is known
685        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
686        let our_peer_id = if config.anonymous_mode {
687            PeerId::generate_anonymous().0
688        } else {
689            PeerId::generate().0
690        };
691
692        // Try dual-stack [::]:port first, fall back to IPv4-only
693        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
694            .bind_tcp(SocketAddr::from((
695                std::net::Ipv6Addr::UNSPECIFIED,
696                config.listen_port,
697            )))
698            .await
699        {
700            Ok(l) => Some(l),
701            Err(_) => factory
702                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
703                .await
704                .ok(),
705        };
706        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
707
708        let mut tracker_manager = TrackerManager::empty(
709            magnet.info_hash(),
710            our_peer_id,
711            config.listen_port,
712            config.peer_dscp,
713            config.anonymous_mode,
714        );
715        // Add tracker URLs from the magnet link (BEP 9 §3.1)
716        for url in &magnet.trackers {
717            tracker_manager.add_tracker_url(url);
718        }
719
720        // BEP 7: include our I2P destination in tracker announces
721        if let Some(ref sam) = sam_session {
722            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
723        }
724
725        let enable_dht = config.enable_dht;
726
727        // M173 Lane B (B6): snapshot the broadcast at construction time
728        // for the initial peer-discovery wiring; future DHT restarts
729        // (B11) deliver new handles via the watch subscription.
730        let dht_initial = dht_rx.current();
731        let dht_v6_initial = dht_v6_rx.current();
732
733        // Start DHT peer discovery if enabled and available
734        let dht_peers_rx = if enable_dht {
735            if let Some(ref dht) = dht_initial {
736                match dht.get_peers(magnet.info_hash()).await {
737                    Ok(rx) => Some(rx),
738                    Err(e) => {
739                        warn!("failed to start DHT v4 get_peers: {e}");
740                        None
741                    }
742                }
743            } else {
744                None
745            }
746        } else {
747            None
748        };
749
750        let dht_v6_peers_rx = if enable_dht {
751            if let Some(ref dht6) = dht_v6_initial {
752                match dht6.get_peers(magnet.info_hash()).await {
753                    Ok(rx) => Some(rx),
754                    Err(e) => {
755                        debug!("failed to start DHT v6 get_peers: {e}");
756                        None
757                    }
758                }
759            } else {
760                None
761            }
762        } else {
763            None
764        };
765
766        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
767        let download_bucket = Arc::new(parking_lot::Mutex::new(
768            crate::rate_limiter::TokenBucket::new(config.download_rate_limit),
769        ));
770        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
771            0,
772            0,
773            0,
774            0,
775            config.upload_rate_limit,
776            config.download_rate_limit,
777        );
778
779        let super_seed = if config.super_seeding {
780            Some(crate::super_seed::SuperSeedState::new())
781        } else {
782            None
783        };
784        // M118: broadcast channel — start with min capacity for magnet (resized on metadata)
785        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
786        let is_share_mode = config.share_mode;
787        let magnet_selected_files = magnet.selected_files.clone();
788        let info_hashes = magnet.info_hashes.clone();
789
790        let (piece_ready_tx, _) = broadcast::channel(64);
791        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(0));
792        let stream_read_semaphore =
793            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
794
795        let choker = Choker::with_algorithms(
796            initial_unchoke_slots(config.max_uploads_per_torrent),
797            config.seed_choking_algorithm,
798            config.choking_algorithm,
799            config.upload_rate_limit,
800            2,
801            20,
802        );
803
804        let (order_map_tx, _order_map_rx_seed) =
805            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
806
807        let actor = TorrentActor {
808            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
809                config.lock_warn_threshold_ms,
810            ),
811            config,
812            info_hash: magnet.info_hash(),
813            our_peer_id,
814            state: TorrentState::FetchingMetadata,
815            disk: None,
816            disk_manager,
817            chunk_tracker: None,
818            lengths: None,
819            num_pieces: 0,
820            streaming_pieces: BTreeSet::new(),
821            time_critical_pieces: BTreeSet::new(),
822            streaming_cursors: Vec::new(),
823            piece_ready_tx,
824            have_watch_tx,
825            have_watch_rx,
826            stream_read_semaphore,
827            file_priorities: Vec::new(),
828            wanted_pieces: Bitfield::new(0),
829            end_game: EndGame::new(),
830            peers: HashMap::new(),
831            unchoke_durations: HashMap::new(),
832            cached_peer_rates: FxHashMap::default(),
833            refill_notify: Arc::new(tokio::sync::Notify::new()),
834            atomic_states: None,
835            block_maps: None,
836            steal_candidates: None,
837            last_steal_populate: Instant::now(),
838            piece_write_guards: None,
839            soft_reap_buf: Vec::new(),
840            eviction_history: std::collections::VecDeque::new(),
841            force_immediate_choker_tick: false,
842            piece_tracker: None,
843            order_map_dirty: false,
844            next_order_map_gen: 0,
845            order_map_tx,
846            piece_owner: Vec::new(),
847            peer_slab: crate::piece_reservation::PeerSlab::new(),
848            priority_pieces: BTreeSet::new(),
849            max_in_flight: 512,
850            reservation_notify: None,
851            last_tick_dispatch_state: None,
852            choker,
853            user_seed_mode: false,
854            user_forced: false,
855            max_connections: 0,
856            peer_states: None,
857            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
858            connect_permits: HashMap::new(),
859            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
860                std::collections::HashMap::new(),
861            )),
862            connect_rx: None,
863            metadata_downloader: Some(MetadataDownloader::new(magnet.info_hash())),
864            downloaded: 0,
865            uploaded: 0,
866            checking_progress: 0.0,
867            total_download: 0,
868            total_upload: 0,
869            total_failed_bytes: 0,
870            total_redundant_bytes: 0,
871            added_time: std::time::SystemTime::now()
872                .duration_since(std::time::UNIX_EPOCH)
873                .map_or(0, |d| d.as_secs() as i64),
874            completed_time: 0,
875            last_download: 0,
876            last_upload: 0,
877            last_seen_complete: 0,
878            active_duration: 0,
879            finished_duration: 0,
880            seeding_duration: 0,
881            active_since: Some(std::time::Instant::now()),
882            state_duration_since: None,
883            started_at: std::time::Instant::now(),
884            moving_storage: false,
885            has_incoming: false,
886            need_save_resume: false,
887            error: String::new(),
888            error_file: -1,
889            cmd_rx,
890            event_tx,
891            event_rx,
892            write_error_rx,
893            write_error_tx,
894            verify_result_rx,
895            verify_result_tx,
896            pending_verify: HashSet::new(),
897            piece_generations: Vec::new(),
898            hash_result_rx,
899            hash_result_tx,
900            meta: None,
901            cached_files: None,
902            listener,
903            utp_socket,
904            utp_socket_v6,
905            tracker_manager,
906            tracker_result_rx: None,
907            dht_rx,
908            dht_v6_rx,
909            dht_enabled: enable_dht,
910            dht_peers_rx,
911            dht_v6_peers_rx,
912            dht_v6_empty_count: 0,
913            dht_v6_last_retry: None,
914            alert_tx,
915            alert_mask,
916            upload_bucket,
917            download_bucket,
918            global_upload_bucket,
919            global_download_bucket,
920            slot_tuner,
921            upload_bytes_interval: 0,
922            peak_download_rate: 0,
923            web_seeds: HashMap::new(),
924            banned_web_seeds: HashSet::new(),
925            web_seed_in_flight: HashMap::new(),
926            web_seed_stats: HashMap::new(),
927            pex_peer_count: 0,
928            lsd_peer_count: 0,
929            super_seed,
930            have_broadcast_tx,
931            suggested_to_peers: HashMap::new(),
932            predictive_have_sent: HashSet::new(),
933
934            ban_manager,
935            ip_filter,
936            piece_contributors: HashMap::new(),
937            parole_pieces: HashMap::new(),
938            external_ip: None,
939            share_lru: std::collections::VecDeque::new(),
940            share_max_pieces: if is_share_mode { 64 } else { 0 },
941            plugins,
942            hash_picker: None,
943            version: irontide_core::TorrentVersion::V1Only,
944            meta_v2: None,
945            info_hashes,
946            dht_v2_peers_rx: None,
947            dht_v6_v2_peers_rx: None,
948            magnet_selected_files,
949            sam_session,
950            i2p_accept_rx: None,
951            i2p_peer_counter: 0,
952            i2p_destinations: HashMap::new(),
953            ssl_manager,
954            rate_limiter_set,
955            auto_sequential_active: false,
956            factory,
957            hash_pool_ref: hash_pool,
958            connect_attempts: 0,
959            connect_failures: 0,
960            choke_rotations: 0,
961            inflight_started: Vec::new(),
962            completed_piece_times: std::collections::VecDeque::new(),
963            piece_steals: 0,
964            holepunch_relayed: 0,
965            holepunch_relay_rate: HashMap::new(),
966            holepunch_cooldowns: HashMap::new(),
967            holepunch_pending: Vec::new(),
968            counters,
969        };
970
971        let spawn_info_hash = actor.info_hash;
972        let join_handle = tokio::spawn(actor.run());
973        tokio::spawn(async move {
974            match join_handle.await {
975                Ok(()) => {
976                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
977                }
978                Err(e) if e.is_panic() => {
979                    let panic_payload = e.into_panic();
980                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
981                        (*s).to_string()
982                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
983                        s.clone()
984                    } else {
985                        "unknown panic payload".to_string()
986                    };
987                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
988                }
989                Err(e) => {
990                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
991                }
992            }
993        });
994        Ok(Self { cmd_tx })
995    }
996
997    /// Send an incoming peer (routed by the session) to this torrent.
998    ///
999    /// # Errors
1000    /// Returns an error if the torrent actor has shut down.
1001    pub async fn send_incoming_peer(
1002        &self,
1003        stream: crate::transport::BoxedStream,
1004        addr: SocketAddr,
1005    ) -> crate::Result<()> {
1006        self.cmd_tx
1007            .send(TorrentCommand::IncomingPeer { stream, addr })
1008            .await
1009            .map_err(|_| crate::Error::Shutdown)
1010    }
1011
1012    /// Query current torrent statistics.
1013    ///
1014    /// # Errors
1015    ///
1016    /// Returns an error if the session is shut down.
1017    pub async fn stats(&self) -> crate::Result<TorrentStats> {
1018        let (tx, rx) = oneshot::channel();
1019        self.cmd_tx
1020            .send(TorrentCommand::Stats { reply: tx })
1021            .await
1022            .map_err(|_| crate::Error::Shutdown)?;
1023        rx.await.map_err(|_| crate::Error::Shutdown)
1024    }
1025
1026    /// v0.173.1: fetch the torrent's current metadata from the `TorrentActor`.
1027    ///
1028    /// Returns `Ok(None)` if metadata has not yet been assembled (magnet
1029    /// pre-resolution), `Ok(Some(meta))` once the info dict is known. Returns
1030    /// `Err(Shutdown)` if the actor has already exited. Callers that need the
1031    /// meta to exist should fall back to `crate::Error::MetadataNotReady` on
1032    /// the `Ok(None)` branch.
1033    ///
1034    /// This replaces `SessionActor.TorrentEntry.meta` as the single source of
1035    /// truth: see `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`
1036    /// for the Class A archaeology.
1037    ///
1038    /// # Errors
1039    ///
1040    /// Returns an error if the session is shut down.
1041    pub async fn get_meta(&self) -> crate::Result<Option<TorrentMetaV1>> {
1042        let (tx, rx) = oneshot::channel();
1043        self.cmd_tx
1044            .send(TorrentCommand::GetMeta { reply: tx })
1045            .await
1046            .map_err(|_| crate::Error::Shutdown)?;
1047        rx.await.map_err(|_| crate::Error::Shutdown)
1048    }
1049
1050    /// Add peer addresses to the available-peer pool.
1051    ///
1052    /// # Errors
1053    ///
1054    /// Returns an error if the session is shut down.
1055    pub async fn add_peers(&self, peers: Vec<SocketAddr>, source: PeerSource) -> crate::Result<()> {
1056        self.cmd_tx
1057            .send(TorrentCommand::AddPeers { peers, source })
1058            .await
1059            .map_err(|_| crate::Error::Shutdown)
1060    }
1061
1062    /// Pause the torrent session (disconnect peers, announce Stopped).
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if the session is shut down.
1067    pub async fn pause(&self) -> crate::Result<()> {
1068        self.cmd_tx
1069            .send(TorrentCommand::Pause)
1070            .await
1071            .map_err(|_| crate::Error::Shutdown)
1072    }
1073
1074    /// Queue a torrent via auto-manage (system-managed pause).
1075    ///
1076    /// # Errors
1077    ///
1078    /// Returns an error if the session is shut down.
1079    pub async fn queue(&self) -> crate::Result<()> {
1080        self.cmd_tx
1081            .send(TorrentCommand::Queue)
1082            .await
1083            .map_err(|_| crate::Error::Shutdown)
1084    }
1085
1086    /// M170: update the qBt-compat category label on this torrent.
1087    ///
1088    /// Pass `None` to clear the label. The change is visible via the
1089    /// next `stats()` call and persists across `save_resume_data`.
1090    ///
1091    /// # Errors
1092    ///
1093    /// Returns an error if the session is shut down.
1094    pub async fn set_category(&self, category: Option<String>) -> crate::Result<()> {
1095        let (tx, rx) = oneshot::channel();
1096        self.cmd_tx
1097            .send(TorrentCommand::SetCategory {
1098                category,
1099                reply: tx,
1100            })
1101            .await
1102            .map_err(|_| crate::Error::Shutdown)?;
1103        rx.await.map_err(|_| crate::Error::Shutdown)
1104    }
1105
1106    /// M171: replace this torrent's tag set wholesale (qBt-compat).
1107    ///
1108    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
1109    /// layer — always a wholesale replacement at the engine layer. The
1110    /// change is visible via the next `stats()` call and persists
1111    /// across `save_resume_data`.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if the session is shut down.
1116    pub async fn set_tags(&self, tags: Vec<String>) -> crate::Result<()> {
1117        let (tx, rx) = oneshot::channel();
1118        self.cmd_tx
1119            .send(TorrentCommand::SetTags { tags, reply: tx })
1120            .await
1121            .map_err(|_| crate::Error::Shutdown)?;
1122        rx.await.map_err(|_| crate::Error::Shutdown)
1123    }
1124
1125    /// Resume a paused torrent session (reconnect, announce Started).
1126    ///
1127    /// # Errors
1128    ///
1129    /// Returns an error if the session is shut down.
1130    pub async fn resume(&self) -> crate::Result<()> {
1131        self.cmd_tx
1132            .send(TorrentCommand::Resume)
1133            .await
1134            .map_err(|_| crate::Error::Shutdown)
1135    }
1136
1137    /// Gracefully shut down the torrent session.
1138    ///
1139    /// # Errors
1140    ///
1141    /// Returns an error if the session is shut down.
1142    pub async fn shutdown(&self) -> crate::Result<()> {
1143        // Best-effort send with timeout — if the channel is full or closed,
1144        // the actor will exit when all senders are dropped anyway.
1145        let _ = tokio::time::timeout(
1146            std::time::Duration::from_secs(5),
1147            self.cmd_tx.send(TorrentCommand::Shutdown),
1148        )
1149        .await;
1150        Ok(())
1151    }
1152
1153    /// Snapshot current torrent state into libtorrent-compatible resume data.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error if the I/O operation fails.
1158    pub async fn save_resume_data(&self) -> crate::Result<irontide_core::FastResumeData> {
1159        let (tx, rx) = oneshot::channel();
1160        self.cmd_tx
1161            .send(TorrentCommand::SaveResumeData { reply: tx })
1162            .await
1163            .map_err(|_| crate::Error::Shutdown)?;
1164        rx.await.map_err(|_| crate::Error::Shutdown)?
1165    }
1166
1167    /// Clear the `need_save_resume` dirty flag after a successful file save.
1168    ///
1169    /// # Errors
1170    /// Returns an error if the torrent actor has shut down.
1171    pub async fn clear_save_resume_flag(&self) -> crate::Result<()> {
1172        self.cmd_tx
1173            .send(TorrentCommand::ClearSaveResumeFlag)
1174            .await
1175            .map_err(|_| crate::Error::Shutdown)
1176    }
1177
1178    /// M245 F1 — atomically take resume data IFF the torrent is dirty.
1179    ///
1180    /// Replaces the racy `stats()` → `save_resume_data()` → `clear_save_resume_flag()`
1181    /// three-step the session's periodic saver used to run per torrent. The
1182    /// actor reads `need_save_resume`, builds the resume data, and clears the
1183    /// flag in ONE indivisible command turn (no `.await` between read and
1184    /// clear), so a dirty mark set concurrently can never be lost to a clear
1185    /// that races the build.
1186    ///
1187    /// Returns `Ok(None)` when the torrent is clean (nothing to write),
1188    /// `Ok(Some(data))` when dirty data was taken and the flag cleared. On a
1189    /// build error the flag is left set so the torrent is retried next cycle.
1190    ///
1191    /// # Errors
1192    /// Returns an error if the torrent actor has shut down, or if building the
1193    /// resume data fails.
1194    pub async fn take_resume_if_dirty(
1195        &self,
1196    ) -> crate::Result<Option<irontide_core::FastResumeData>> {
1197        let (tx, rx) = oneshot::channel();
1198        self.cmd_tx
1199            .send(TorrentCommand::TakeResumeIfDirty { reply: tx })
1200            .await
1201            .map_err(|_| crate::Error::Shutdown)?;
1202        rx.await.map_err(|_| crate::Error::Shutdown)?
1203    }
1204
1205    /// M245 F1 — re-arm `need_save_resume` after a failed resume WRITE.
1206    ///
1207    /// The session saver calls this when the disk write fails AFTER
1208    /// [`Self::take_resume_if_dirty`] already cleared the flag, so the torrent
1209    /// is re-marked dirty and retried on the next save cycle instead of being
1210    /// silently dropped. Fire-and-forget.
1211    ///
1212    /// # Errors
1213    /// Returns an error if the torrent actor has shut down.
1214    pub async fn mark_resume_dirty(&self) -> crate::Result<()> {
1215        self.cmd_tx
1216            .send(TorrentCommand::MarkResumeDirty)
1217            .await
1218            .map_err(|_| crate::Error::Shutdown)
1219    }
1220
1221    /// Restore a piece bitmap from resume data (M161 Phase 4).
1222    ///
1223    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
1224    /// Returns an error if the bitfield length does not match the torrent's
1225    /// piece count or if the chunk tracker is not yet initialized.
1226    ///
1227    /// # Errors
1228    ///
1229    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1230    /// Returns [`crate::Error::InvalidSettings`] if the bitfield is invalid.
1231    pub async fn restore_resume_bitmap(&self, pieces: Vec<u8>) -> crate::Result<()> {
1232        let (tx, rx) = oneshot::channel();
1233        self.cmd_tx
1234            .send(TorrentCommand::RestoreResumeBitmap { pieces, reply: tx })
1235            .await
1236            .map_err(|_| crate::Error::Shutdown)?;
1237        rx.await.map_err(|_| crate::Error::Shutdown)?
1238    }
1239
1240    /// M178: Restore per-URL web-seed stats from resume data.
1241    ///
1242    /// # Errors
1243    ///
1244    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1245    pub async fn restore_web_seed_stats(
1246        &self,
1247        stats: HashMap<String, irontide_core::WebSeedStats>,
1248    ) -> crate::Result<()> {
1249        let (tx, rx) = oneshot::channel();
1250        self.cmd_tx
1251            .send(TorrentCommand::RestoreWebSeedStats { stats, reply: tx })
1252            .await
1253            .map_err(|_| crate::Error::Shutdown)?;
1254        rx.await.map_err(|_| crate::Error::Shutdown)?
1255    }
1256
1257    /// M178 Lane B3: cumulative `(pex_peer_count, lsd_peer_count)` for
1258    /// this torrent. Both counters track UNIQUE peers — duplicate
1259    /// announcements from the same peer count once.
1260    ///
1261    /// # Errors
1262    ///
1263    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1264    pub async fn peer_source_counts(&self) -> crate::Result<(usize, usize)> {
1265        let (tx, rx) = oneshot::channel();
1266        self.cmd_tx
1267            .send(TorrentCommand::GetPeerSourceCounts { reply: tx })
1268            .await
1269            .map_err(|_| crate::Error::Shutdown)?;
1270        rx.await.map_err(|_| crate::Error::Shutdown)
1271    }
1272
1273    /// Snapshot the per-peer cumulative unchoke duration for this torrent.
1274    ///
1275    /// # Errors
1276    ///
1277    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
1278    pub async fn query_unchoke_durations(
1279        &self,
1280    ) -> crate::Result<HashMap<SocketAddr, std::time::Duration>> {
1281        let (tx, rx) = oneshot::channel();
1282        self.cmd_tx
1283            .send(TorrentCommand::QueryUnchokeDurations { reply: tx })
1284            .await
1285            .map_err(|_| crate::Error::Shutdown)?;
1286        rx.await.map_err(|_| crate::Error::Shutdown)
1287    }
1288
1289    /// M178 Lane C: snapshot per-URL `WebSeedStats` for this torrent.
1290    ///
1291    /// # Errors
1292    ///
1293    /// Returns [`crate::Error::Shutdown`] if the actor has stopped.
1294    pub async fn get_web_seed_stats(&self) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
1295        let (tx, rx) = oneshot::channel();
1296        self.cmd_tx
1297            .send(TorrentCommand::GetWebSeedStats { reply: tx })
1298            .await
1299            .map_err(|_| crate::Error::Shutdown)?;
1300        rx.await.map_err(|_| crate::Error::Shutdown)
1301    }
1302
1303    /// Set the download priority for a specific file.
1304    ///
1305    /// # Errors
1306    ///
1307    /// Returns an error if the session is shut down.
1308    pub async fn set_file_priority(
1309        &self,
1310        index: usize,
1311        priority: irontide_core::FilePriority,
1312    ) -> crate::Result<()> {
1313        let (tx, rx) = oneshot::channel();
1314        self.cmd_tx
1315            .send(TorrentCommand::SetFilePriority {
1316                index,
1317                priority,
1318                reply: tx,
1319            })
1320            .await
1321            .map_err(|_| crate::Error::Shutdown)?;
1322        rx.await.map_err(|_| crate::Error::Shutdown)?
1323    }
1324
1325    /// Get the current per-file priorities.
1326    ///
1327    /// # Errors
1328    ///
1329    /// Returns an error if the session is shut down.
1330    pub async fn file_priorities(&self) -> crate::Result<Vec<irontide_core::FilePriority>> {
1331        let (tx, rx) = oneshot::channel();
1332        self.cmd_tx
1333            .send(TorrentCommand::FilePriorities { reply: tx })
1334            .await
1335            .map_err(|_| crate::Error::Shutdown)?;
1336        rx.await.map_err(|_| crate::Error::Shutdown)
1337    }
1338
1339    /// Get the list of all configured trackers with their status.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns an error if the session is shut down.
1344    pub async fn tracker_list(&self) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
1345        let (tx, rx) = oneshot::channel();
1346        self.cmd_tx
1347            .send(TorrentCommand::TrackerList { reply: tx })
1348            .await
1349            .map_err(|_| crate::Error::Shutdown)?;
1350        rx.await.map_err(|_| crate::Error::Shutdown)
1351    }
1352
1353    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged).
1354    ///
1355    /// # Errors
1356    ///
1357    /// Returns an error if the session is shut down.
1358    pub async fn get_web_seeds(&self) -> crate::Result<Vec<String>> {
1359        let (tx, rx) = oneshot::channel();
1360        self.cmd_tx
1361            .send(TorrentCommand::GetWebSeeds { reply: tx })
1362            .await
1363            .map_err(|_| crate::Error::Shutdown)?;
1364        rx.await.map_err(|_| crate::Error::Shutdown)
1365    }
1366
1367    /// M171 Lane B: snapshot the per-piece qBt state codes.
1368    ///
1369    /// # Errors
1370    ///
1371    /// Returns an error if the session is shut down.
1372    pub async fn get_piece_states(&self) -> crate::Result<Vec<u8>> {
1373        let (tx, rx) = oneshot::channel();
1374        self.cmd_tx
1375            .send(TorrentCommand::GetPieceStates { reply: tx })
1376            .await
1377            .map_err(|_| crate::Error::Shutdown)?;
1378        rx.await.map_err(|_| crate::Error::Shutdown)
1379    }
1380
1381    /// M171 Lane B: paginated piece hash list.
1382    ///
1383    /// `offset` and `limit` are clamped to the real hash count inside
1384    /// the actor — callers can pass arbitrary values without overflow
1385    /// concerns.
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns an error if the session is shut down.
1390    pub async fn get_piece_hashes(&self, offset: u32, limit: u32) -> crate::Result<Vec<String>> {
1391        let (tx, rx) = oneshot::channel();
1392        self.cmd_tx
1393            .send(TorrentCommand::GetPieceHashes {
1394                offset,
1395                limit,
1396                reply: tx,
1397            })
1398            .await
1399            .map_err(|_| crate::Error::Shutdown)?;
1400        // M245 L3: the actor returns the raw windowed hash bytes; hex-encode
1401        // here, OFF the recv loop. Output stays byte-identical to the pre-M245
1402        // `Vec<String>` (40-char SHA-1 / 64-char SHA-256 hex).
1403        let raw = rx.await.map_err(|_| crate::Error::Shutdown)?;
1404        Ok(raw.iter().map(hex::encode).collect())
1405    }
1406
1407    /// Force all trackers to re-announce immediately.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the session is shut down.
1412    pub async fn force_reannounce(&self) -> crate::Result<()> {
1413        self.cmd_tx
1414            .send(TorrentCommand::ForceReannounce)
1415            .await
1416            .map_err(|_| crate::Error::Shutdown)
1417    }
1418
1419    /// Scrape trackers for seeder/leecher counts.
1420    ///
1421    /// # Errors
1422    ///
1423    /// Returns an error if the session is shut down.
1424    pub async fn scrape(&self) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
1425        let (tx, rx) = oneshot::channel();
1426        self.cmd_tx
1427            .send(TorrentCommand::Scrape { reply: tx })
1428            .await
1429            .map_err(|_| crate::Error::Shutdown)?;
1430        rx.await.map_err(|_| crate::Error::Shutdown)
1431    }
1432
1433    /// Open a streaming reader for a file within the torrent.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns an error if the session is shut down.
1438    pub async fn open_file(
1439        &self,
1440        file_index: usize,
1441    ) -> crate::Result<crate::streaming::FileStream> {
1442        let (tx, rx) = oneshot::channel();
1443        self.cmd_tx
1444            .send(TorrentCommand::OpenFile {
1445                file_index,
1446                reply: tx,
1447            })
1448            .await
1449            .map_err(|_| crate::Error::Shutdown)?;
1450        let handle = rx.await.map_err(|_| crate::Error::Shutdown)??;
1451        Ok(crate::streaming::FileStream::from_handle(handle))
1452    }
1453
1454    /// Update the external IP for BEP 40 peer priority sorting.
1455    ///
1456    /// # Errors
1457    /// Returns an error if the torrent actor has shut down.
1458    pub async fn update_external_ip(&self, ip: std::net::IpAddr) -> crate::Result<()> {
1459        self.cmd_tx
1460            .send(TorrentCommand::UpdateExternalIp { ip })
1461            .await
1462            .map_err(|_| crate::Error::Shutdown)
1463    }
1464
1465    /// Move torrent data files to a new download directory.
1466    ///
1467    /// Relocates existing files (rename or copy+delete), re-registers storage
1468    /// with the disk manager, and fires a `StorageMoved` alert on success.
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if the session is shut down.
1473    pub async fn move_storage(&self, new_path: std::path::PathBuf) -> crate::Result<()> {
1474        let (tx, rx) = oneshot::channel();
1475        self.cmd_tx
1476            .send(TorrentCommand::MoveStorage {
1477                new_path,
1478                reply: tx,
1479            })
1480            .await
1481            .map_err(|_| crate::Error::Shutdown)?;
1482        rx.await.map_err(|_| crate::Error::Shutdown)?
1483    }
1484
1485    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
1486    ///
1487    /// # Errors
1488    ///
1489    /// Returns an error if the data cannot be parsed or I/O fails.
1490    pub async fn set_download_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1491        let (tx, rx) = oneshot::channel();
1492        self.cmd_tx
1493            .send(TorrentCommand::SetDownloadLimit {
1494                bytes_per_sec,
1495                reply: tx,
1496            })
1497            .await
1498            .map_err(|_| crate::Error::Shutdown)?;
1499        rx.await.map_err(|_| crate::Error::Shutdown)
1500    }
1501
1502    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
1503    ///
1504    /// # Errors
1505    ///
1506    /// Returns an error if the data cannot be parsed or I/O fails.
1507    pub async fn set_upload_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1508        let (tx, rx) = oneshot::channel();
1509        self.cmd_tx
1510            .send(TorrentCommand::SetUploadLimit {
1511                bytes_per_sec,
1512                reply: tx,
1513            })
1514            .await
1515            .map_err(|_| crate::Error::Shutdown)?;
1516        rx.await.map_err(|_| crate::Error::Shutdown)
1517    }
1518
1519    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
1520    ///
1521    /// # Errors
1522    ///
1523    /// Returns an error if the data cannot be parsed or I/O fails.
1524    pub async fn download_limit(&self) -> crate::Result<u64> {
1525        let (tx, rx) = oneshot::channel();
1526        self.cmd_tx
1527            .send(TorrentCommand::DownloadLimit { reply: tx })
1528            .await
1529            .map_err(|_| crate::Error::Shutdown)?;
1530        rx.await.map_err(|_| crate::Error::Shutdown)
1531    }
1532
1533    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
1534    ///
1535    /// # Errors
1536    ///
1537    /// Returns an error if the data cannot be parsed or I/O fails.
1538    pub async fn upload_limit(&self) -> crate::Result<u64> {
1539        let (tx, rx) = oneshot::channel();
1540        self.cmd_tx
1541            .send(TorrentCommand::UploadLimit { reply: tx })
1542            .await
1543            .map_err(|_| crate::Error::Shutdown)?;
1544        rx.await.map_err(|_| crate::Error::Shutdown)
1545    }
1546
1547    /// Enable or disable sequential (in-order) piece downloading.
1548    ///
1549    /// # Errors
1550    ///
1551    /// Returns an error if the data cannot be parsed or I/O fails.
1552    pub async fn set_sequential_download(&self, enabled: bool) -> crate::Result<()> {
1553        let (tx, rx) = oneshot::channel();
1554        self.cmd_tx
1555            .send(TorrentCommand::SetSequentialDownload { enabled, reply: tx })
1556            .await
1557            .map_err(|_| crate::Error::Shutdown)?;
1558        rx.await.map_err(|_| crate::Error::Shutdown)
1559    }
1560
1561    /// Query whether sequential downloading is enabled.
1562    ///
1563    /// # Errors
1564    ///
1565    /// Returns an error if the data cannot be parsed or I/O fails.
1566    pub async fn is_sequential_download(&self) -> crate::Result<bool> {
1567        let (tx, rx) = oneshot::channel();
1568        self.cmd_tx
1569            .send(TorrentCommand::IsSequentialDownload { reply: tx })
1570            .await
1571            .map_err(|_| crate::Error::Shutdown)?;
1572        rx.await.map_err(|_| crate::Error::Shutdown)
1573    }
1574
1575    /// Enable or disable BEP 16 super seeding mode.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if the session is shut down.
1580    pub async fn set_super_seeding(&self, enabled: bool) -> crate::Result<()> {
1581        let (tx, rx) = oneshot::channel();
1582        self.cmd_tx
1583            .send(TorrentCommand::SetSuperSeeding { enabled, reply: tx })
1584            .await
1585            .map_err(|_| crate::Error::Shutdown)?;
1586        rx.await.map_err(|_| crate::Error::Shutdown)
1587    }
1588
1589    /// Query whether BEP 16 super seeding mode is enabled.
1590    ///
1591    /// # Errors
1592    ///
1593    /// Returns an error if the session is shut down.
1594    pub async fn is_super_seeding(&self) -> crate::Result<bool> {
1595        let (tx, rx) = oneshot::channel();
1596        self.cmd_tx
1597            .send(TorrentCommand::IsSuperSeeding { reply: tx })
1598            .await
1599            .map_err(|_| crate::Error::Shutdown)?;
1600        rx.await.map_err(|_| crate::Error::Shutdown)
1601    }
1602
1603    /// Enable or disable user-requested seed-only mode (M159).
1604    ///
1605    /// When `enabled` is `true`, the actor stops scheduling new block requests
1606    /// and cancels all in-flight requests, but keeps existing peers connected
1607    /// and continues serving uploads. Toggling back to `false` restores normal
1608    /// piece scheduling.
1609    ///
1610    /// # Errors
1611    ///
1612    /// Returns [`crate::Error::Shutdown`] if the torrent actor has terminated.
1613    pub async fn set_seed_mode(&self, enabled: bool) -> crate::Result<()> {
1614        let (tx, rx) = oneshot::channel();
1615        self.cmd_tx
1616            .send(TorrentCommand::SetSeedMode { enabled, reply: tx })
1617            .await
1618            .map_err(|_| crate::Error::Shutdown)?;
1619        rx.await.map_err(|_| crate::Error::Shutdown)
1620    }
1621
1622    /// Add a new tracker URL to this torrent (fire-and-forget).
1623    ///
1624    /// The URL is validated and deduplicated by the tracker manager.
1625    ///
1626    /// # Errors
1627    ///
1628    /// Returns an error if the session is shut down.
1629    pub async fn add_tracker(&self, url: String) -> crate::Result<()> {
1630        self.cmd_tx
1631            .send(TorrentCommand::AddTracker { url })
1632            .await
1633            .map_err(|_| crate::Error::Shutdown)
1634    }
1635
1636    /// Replace all tracker URLs for this torrent.
1637    ///
1638    /// # Errors
1639    ///
1640    /// Returns an error if the session is shut down.
1641    pub async fn replace_trackers(&self, urls: Vec<String>) -> crate::Result<()> {
1642        let (tx, rx) = oneshot::channel();
1643        self.cmd_tx
1644            .send(TorrentCommand::ReplaceTrackers { urls, reply: tx })
1645            .await
1646            .map_err(|_| crate::Error::Shutdown)?;
1647        rx.await.map_err(|_| crate::Error::Shutdown)
1648    }
1649
1650    /// Trigger a full piece verification (force recheck).
1651    ///
1652    /// Transitions the torrent through `Checking` state, clears all piece
1653    /// completion data, re-verifies every piece against its hash, then
1654    /// transitions to `Seeding` (all valid) or `Downloading` (some missing).
1655    /// Returns after the check is complete.
1656    ///
1657    /// # Errors
1658    ///
1659    /// Returns an error if the session is shut down.
1660    pub async fn force_recheck(&self) -> crate::Result<()> {
1661        let (tx, rx) = oneshot::channel();
1662        self.cmd_tx
1663            .send(TorrentCommand::ForceRecheck { reply: tx })
1664            .await
1665            .map_err(|_| crate::Error::Shutdown)?;
1666        rx.await.map_err(|_| crate::Error::Shutdown)?
1667    }
1668
1669    /// Rename a file within the torrent on disk.
1670    ///
1671    /// Changes the filename of the specified file (by index) to `new_name`.
1672    /// The file stays in the same directory; only the filename component changes.
1673    /// Fires a `FileRenamed` alert on success.
1674    ///
1675    /// # Errors
1676    ///
1677    /// Returns an error if the session is shut down.
1678    pub async fn rename_file(&self, file_index: usize, new_name: String) -> crate::Result<()> {
1679        let (tx, rx) = oneshot::channel();
1680        self.cmd_tx
1681            .send(TorrentCommand::RenameFile {
1682                file_index,
1683                new_name,
1684                reply: tx,
1685            })
1686            .await
1687            .map_err(|_| crate::Error::Shutdown)?;
1688        rx.await.map_err(|_| crate::Error::Shutdown)?
1689    }
1690
1691    /// Route an incoming SSL peer (TLS already completed) to this torrent (M42).
1692    ///
1693    /// # Errors
1694    /// Returns an error if the torrent actor has shut down.
1695    pub async fn spawn_ssl_peer(
1696        &self,
1697        addr: SocketAddr,
1698        stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1699    ) -> crate::Result<()> {
1700        self.cmd_tx
1701            .send(TorrentCommand::SpawnSslPeer {
1702                addr,
1703                stream: crate::types::BoxedAsyncStream(Box::new(stream)),
1704            })
1705            .await
1706            .map_err(|_| crate::Error::Shutdown)
1707    }
1708
1709    /// Set the per-torrent maximum number of connections (0 = use global default).
1710    ///
1711    /// # Errors
1712    ///
1713    /// Returns an error if the connection or binding fails.
1714    pub async fn set_max_connections(&self, limit: usize) -> crate::Result<()> {
1715        let (tx, rx) = oneshot::channel();
1716        self.cmd_tx
1717            .send(TorrentCommand::SetMaxConnections { limit, reply: tx })
1718            .await
1719            .map_err(|_| crate::Error::Shutdown)?;
1720        rx.await.map_err(|_| crate::Error::Shutdown)
1721    }
1722
1723    /// Get the current per-torrent maximum connection limit (0 = use global default).
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns an error if the connection or binding fails.
1728    pub async fn max_connections(&self) -> crate::Result<usize> {
1729        let (tx, rx) = oneshot::channel();
1730        self.cmd_tx
1731            .send(TorrentCommand::MaxConnections { reply: tx })
1732            .await
1733            .map_err(|_| crate::Error::Shutdown)?;
1734        rx.await.map_err(|_| crate::Error::Shutdown)
1735    }
1736
1737    /// Set the per-torrent maximum number of upload slots (unchoke slots).
1738    ///
1739    /// # Errors
1740    ///
1741    /// Returns an error if the data cannot be parsed or I/O fails.
1742    pub async fn set_max_uploads(&self, limit: usize) -> crate::Result<()> {
1743        let (tx, rx) = oneshot::channel();
1744        self.cmd_tx
1745            .send(TorrentCommand::SetMaxUploads { limit, reply: tx })
1746            .await
1747            .map_err(|_| crate::Error::Shutdown)?;
1748        rx.await.map_err(|_| crate::Error::Shutdown)
1749    }
1750
1751    /// Get the current per-torrent maximum upload slots (unchoke slots).
1752    ///
1753    /// # Errors
1754    ///
1755    /// Returns an error if the data cannot be parsed or I/O fails.
1756    pub async fn max_uploads(&self) -> crate::Result<usize> {
1757        let (tx, rx) = oneshot::channel();
1758        self.cmd_tx
1759            .send(TorrentCommand::MaxUploads { reply: tx })
1760            .await
1761            .map_err(|_| crate::Error::Shutdown)?;
1762        rx.await.map_err(|_| crate::Error::Shutdown)
1763    }
1764
1765    /// Get per-peer details for all connected peers.
1766    ///
1767    /// # Errors
1768    ///
1769    /// Returns an error if the session is shut down.
1770    pub async fn get_peer_info(&self) -> crate::Result<Vec<PeerInfo>> {
1771        let (tx, rx) = oneshot::channel();
1772        self.cmd_tx
1773            .send(TorrentCommand::GetPeerInfo { reply: tx })
1774            .await
1775            .map_err(|_| crate::Error::Shutdown)?;
1776        rx.await.map_err(|_| crate::Error::Shutdown)
1777    }
1778
1779    /// Get in-flight piece download status (the download queue).
1780    ///
1781    /// # Errors
1782    ///
1783    /// Returns an error if the data cannot be parsed or I/O fails.
1784    pub async fn get_download_queue(&self) -> crate::Result<Vec<PartialPieceInfo>> {
1785        let (tx, rx) = oneshot::channel();
1786        self.cmd_tx
1787            .send(TorrentCommand::GetDownloadQueue { reply: tx })
1788            .await
1789            .map_err(|_| crate::Error::Shutdown)?;
1790        rx.await.map_err(|_| crate::Error::Shutdown)
1791    }
1792
1793    /// Check whether a specific piece has been downloaded.
1794    ///
1795    /// # Errors
1796    ///
1797    /// Returns an error if the session is shut down.
1798    pub async fn have_piece(&self, index: u32) -> crate::Result<bool> {
1799        let (tx, rx) = oneshot::channel();
1800        self.cmd_tx
1801            .send(TorrentCommand::HavePiece { index, reply: tx })
1802            .await
1803            .map_err(|_| crate::Error::Shutdown)?;
1804        rx.await.map_err(|_| crate::Error::Shutdown)
1805    }
1806
1807    /// Get per-piece availability counts from connected peers.
1808    ///
1809    /// # Errors
1810    ///
1811    /// Returns an error if the session is shut down.
1812    pub async fn piece_availability(&self) -> crate::Result<Vec<u32>> {
1813        let (tx, rx) = oneshot::channel();
1814        self.cmd_tx
1815            .send(TorrentCommand::PieceAvailability { reply: tx })
1816            .await
1817            .map_err(|_| crate::Error::Shutdown)?;
1818        rx.await.map_err(|_| crate::Error::Shutdown)
1819    }
1820
1821    /// Get per-file bytes-downloaded progress.
1822    ///
1823    /// # Errors
1824    ///
1825    /// Returns an error if the session is shut down.
1826    pub async fn file_progress(&self) -> crate::Result<Vec<u64>> {
1827        let (tx, rx) = oneshot::channel();
1828        self.cmd_tx
1829            .send(TorrentCommand::FileProgress { reply: tx })
1830            .await
1831            .map_err(|_| crate::Error::Shutdown)?;
1832        rx.await.map_err(|_| crate::Error::Shutdown)
1833    }
1834
1835    /// Get the torrent's identity hashes (v1 and/or v2).
1836    ///
1837    /// # Errors
1838    ///
1839    /// Returns an error if the session is shut down.
1840    pub async fn info_hashes(&self) -> crate::Result<irontide_core::InfoHashes> {
1841        let (tx, rx) = oneshot::channel();
1842        self.cmd_tx
1843            .send(TorrentCommand::InfoHashes { reply: tx })
1844            .await
1845            .map_err(|_| crate::Error::Shutdown)?;
1846        rx.await.map_err(|_| crate::Error::Shutdown)
1847    }
1848
1849    /// Get the full v1 metainfo, if available.
1850    ///
1851    /// Returns `None` for magnet links before metadata has been received.
1852    ///
1853    /// # Errors
1854    ///
1855    /// Returns an error if the session is shut down.
1856    pub async fn torrent_file(&self) -> crate::Result<Option<TorrentMetaV1>> {
1857        let (tx, rx) = oneshot::channel();
1858        self.cmd_tx
1859            .send(TorrentCommand::TorrentFile { reply: tx })
1860            .await
1861            .map_err(|_| crate::Error::Shutdown)?;
1862        rx.await.map_err(|_| crate::Error::Shutdown)
1863    }
1864
1865    /// Get the full v2 metainfo, if available.
1866    ///
1867    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
1868    /// links before metadata has been received.
1869    ///
1870    /// # Errors
1871    ///
1872    /// Returns an error if the session is shut down.
1873    pub async fn torrent_file_v2(&self) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
1874        let (tx, rx) = oneshot::channel();
1875        self.cmd_tx
1876            .send(TorrentCommand::TorrentFileV2 { reply: tx })
1877            .await
1878            .map_err(|_| crate::Error::Shutdown)?;
1879        rx.await.map_err(|_| crate::Error::Shutdown)
1880    }
1881
1882    /// Force an immediate DHT announce for this torrent.
1883    ///
1884    /// Fire-and-forget at the torrent level — the DHT announce is best-effort.
1885    ///
1886    /// # Errors
1887    ///
1888    /// Returns an error if the session is shut down.
1889    pub async fn force_dht_announce(&self) -> crate::Result<()> {
1890        self.cmd_tx
1891            .send(TorrentCommand::ForceDhtAnnounce)
1892            .await
1893            .map_err(|_| crate::Error::Shutdown)
1894    }
1895
1896    /// Read all data for a specific piece from disk.
1897    ///
1898    /// Returns the complete piece data as `Bytes`. The piece must have been
1899    /// downloaded already; use [`have_piece`](Self::have_piece) to check first.
1900    ///
1901    /// # Errors
1902    ///
1903    /// Returns an error if the data cannot be parsed or I/O fails.
1904    pub async fn read_piece(&self, index: u32) -> crate::Result<Bytes> {
1905        let (tx, rx) = oneshot::channel();
1906        self.cmd_tx
1907            .send(TorrentCommand::ReadPiece { index, reply: tx })
1908            .await
1909            .map_err(|_| crate::Error::Shutdown)?;
1910        rx.await.map_err(|_| crate::Error::Shutdown)?
1911    }
1912
1913    /// Flush the disk write cache, ensuring all buffered writes are persisted.
1914    ///
1915    /// # Errors
1916    ///
1917    /// Returns an error if the session is shut down.
1918    pub async fn flush_cache(&self) -> crate::Result<()> {
1919        let (tx, rx) = oneshot::channel();
1920        self.cmd_tx
1921            .send(TorrentCommand::FlushCache { reply: tx })
1922            .await
1923            .map_err(|_| crate::Error::Shutdown)?;
1924        rx.await.map_err(|_| crate::Error::Shutdown)?
1925    }
1926
1927    /// Check whether this handle refers to a live torrent.
1928    ///
1929    /// Returns `false` after the torrent has been removed or shut down.
1930    /// This is a synchronous check on the channel state — no command dispatch.
1931    #[must_use]
1932    pub fn is_valid(&self) -> bool {
1933        !self.cmd_tx.is_closed()
1934    }
1935
1936    /// Clear any error state on the torrent and resume if it was paused due to error.
1937    ///
1938    /// # Errors
1939    ///
1940    /// Returns an error if the session is shut down.
1941    pub async fn clear_error(&self) -> crate::Result<()> {
1942        self.cmd_tx
1943            .send(TorrentCommand::ClearError)
1944            .await
1945            .map_err(|_| crate::Error::Shutdown)
1946    }
1947
1948    /// Get per-file open/mode status based on the current torrent state.
1949    ///
1950    /// Returns one [`crate::types::FileStatus`] entry per file in the torrent.
1951    ///
1952    /// # Errors
1953    ///
1954    /// Returns an error if the session is shut down.
1955    pub async fn file_status(&self) -> crate::Result<Vec<crate::types::FileStatus>> {
1956        let (tx, rx) = oneshot::channel();
1957        self.cmd_tx
1958            .send(TorrentCommand::FileStatus { reply: tx })
1959            .await
1960            .map_err(|_| crate::Error::Shutdown)?;
1961        rx.await.map_err(|_| crate::Error::Shutdown)
1962    }
1963
1964    /// Read the current torrent state as a [`TorrentFlags`] bitflag set.
1965    ///
1966    /// # Errors
1967    ///
1968    /// Returns an error if the session is shut down.
1969    pub async fn flags(&self) -> crate::Result<crate::types::TorrentFlags> {
1970        let (tx, rx) = oneshot::channel();
1971        self.cmd_tx
1972            .send(TorrentCommand::Flags { reply: tx })
1973            .await
1974            .map_err(|_| crate::Error::Shutdown)?;
1975        rx.await.map_err(|_| crate::Error::Shutdown)
1976    }
1977
1978    /// Set (enable) the specified torrent flags.
1979    ///
1980    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1981    ///
1982    /// # Errors
1983    ///
1984    /// Returns an error if the session is shut down.
1985    pub async fn set_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1986        let (tx, rx) = oneshot::channel();
1987        self.cmd_tx
1988            .send(TorrentCommand::SetFlags { flags, reply: tx })
1989            .await
1990            .map_err(|_| crate::Error::Shutdown)?;
1991        rx.await.map_err(|_| crate::Error::Shutdown)
1992    }
1993
1994    /// Unset (disable) the specified torrent flags.
1995    ///
1996    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1997    ///
1998    /// # Errors
1999    ///
2000    /// Returns an error if the session is shut down.
2001    pub async fn unset_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
2002        let (tx, rx) = oneshot::channel();
2003        self.cmd_tx
2004            .send(TorrentCommand::UnsetFlags { flags, reply: tx })
2005            .await
2006            .map_err(|_| crate::Error::Shutdown)?;
2007        rx.await.map_err(|_| crate::Error::Shutdown)
2008    }
2009
2010    /// Immediately initiate a peer connection to the given address.
2011    ///
2012    /// Bypasses the normal peer selection queue — the connection attempt starts
2013    /// right away. Fire-and-forget: no reply is sent.
2014    ///
2015    /// # Errors
2016    ///
2017    /// Returns an error if the connection or binding fails.
2018    pub async fn connect_peer(&self, addr: SocketAddr) -> crate::Result<()> {
2019        self.cmd_tx
2020            .send(TorrentCommand::ConnectPeer { addr })
2021            .await
2022            .map_err(|_| crate::Error::Shutdown)
2023    }
2024
2025    /// M147: Send pre-resolved metadata from the background resolver.
2026    ///
2027    /// Fire-and-forget: uses `try_send` to avoid blocking the resolver task.
2028    /// If the channel is full or closed, the pre-resolved metadata is silently
2029    /// discarded (the `TorrentActor`'s own `FetchingMetadata` phase will handle it).
2030    pub fn send_pre_resolved_metadata(&self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
2031        let _ = self
2032            .cmd_tx
2033            .try_send(TorrentCommand::PreResolvedMetadata { info_bytes, peers });
2034    }
2035
2036    /// **TEST-ONLY (v0.173.2).** Synchronous counterpart to
2037    /// [`Self::send_pre_resolved_metadata`] that waits for the actor to
2038    /// finish processing before returning.
2039    ///
2040    /// Uses backpressured `cmd_tx.send().await` plus a oneshot completion
2041    /// ack, so `test_inject_metadata(...).await` resolves only after the
2042    /// metadata has been processed by the `TorrentActor`. See
2043    /// `TorrentCommand::TestInjectMetadata` for the rationale.
2044    ///
2045    /// # Errors
2046    /// - [`crate::Error::Shutdown`] if the torrent command channel is closed.
2047    #[cfg(feature = "test-util")]
2048    pub(crate) async fn test_inject_metadata(&self, info_bytes: Vec<u8>) -> crate::Result<()> {
2049        let (tx, rx) = tokio::sync::oneshot::channel();
2050        self.cmd_tx
2051            .send(TorrentCommand::TestInjectMetadata {
2052                info_bytes,
2053                reply: tx,
2054            })
2055            .await
2056            .map_err(|_| crate::Error::Shutdown)?;
2057        rx.await.map_err(|_| crate::Error::Shutdown)?;
2058        Ok(())
2059    }
2060}
2061
2062// ---------------------------------------------------------------------------
2063// M116: Cached file metadata for zero-allocation piece completion checks
2064// ---------------------------------------------------------------------------
2065
2066/// Pre-computed file metadata for zero-allocation piece completion checks.
2067#[derive(Debug, Clone)]
2068pub(crate) struct CachedFileEntry {
2069    pub(crate) index: usize,
2070    #[allow(dead_code)] // Used in tests; retained for future diagnostics
2071    pub(crate) length: u64,
2072    pub(crate) first_piece: u32,
2073    pub(crate) last_piece: u32,
2074}
2075
2076/// Cached file-to-piece mapping, computed once at torrent registration.
2077#[derive(Debug, Clone)]
2078pub(crate) struct CachedFileInfo {
2079    pub(crate) entries: Vec<CachedFileEntry>,
2080}
2081
2082pub(crate) fn build_cached_file_info(meta: &TorrentMetaV1, lengths: &Lengths) -> CachedFileInfo {
2083    let piece_length = lengths.piece_length();
2084    let files = meta.info.files();
2085    let mut entries = Vec::with_capacity(files.len());
2086    let mut offset = 0u64;
2087    for (index, file) in files.iter().enumerate() {
2088        let first_piece = (offset / piece_length) as u32;
2089        let last_piece = if file.length == 0 {
2090            first_piece
2091        } else {
2092            ((offset + file.length - 1) / piece_length) as u32
2093        };
2094        entries.push(CachedFileEntry {
2095            index,
2096            length: file.length,
2097            first_piece,
2098            last_piece,
2099        });
2100        offset += file.length;
2101    }
2102    CachedFileInfo { entries }
2103}
2104
2105// ---------------------------------------------------------------------------
2106// TorrentActor — internal single-owner event loop
2107// ---------------------------------------------------------------------------
2108
2109pub(crate) struct TorrentActor {
2110    pub(crate) config: TorrentConfig,
2111    /// M120: Lock timing settings for hot-path diagnostics.
2112    pub(crate) lock_timing: crate::timed_lock::LockTimingSettings,
2113    pub(crate) info_hash: Id20,
2114    pub(crate) our_peer_id: Id20,
2115    pub(crate) state: TorrentState,
2116
2117    // Disk I/O (None in magnet mode until metadata arrives)
2118    pub(crate) disk: Option<DiskHandle>,
2119    pub(crate) disk_manager: DiskManagerHandle,
2120    pub(crate) chunk_tracker: Option<ChunkTracker>,
2121    pub(crate) lengths: Option<Lengths>,
2122    pub(crate) num_pieces: u32,
2123
2124    // Piece management
2125    pub(crate) file_priorities: Vec<FilePriority>,
2126    pub(crate) wanted_pieces: Bitfield,
2127    pub(crate) end_game: EndGame,
2128
2129    // Streaming (M28)
2130    pub(crate) streaming_pieces: BTreeSet<u32>,
2131    pub(crate) time_critical_pieces: BTreeSet<u32>,
2132    pub(crate) streaming_cursors: Vec<crate::streaming::StreamingCursor>,
2133    pub(crate) piece_ready_tx: broadcast::Sender<u32>,
2134    pub(crate) have_watch_tx: tokio::sync::watch::Sender<Bitfield>,
2135    pub(crate) have_watch_rx: tokio::sync::watch::Receiver<Bitfield>,
2136    pub(crate) stream_read_semaphore: Arc<tokio::sync::Semaphore>,
2137
2138    // Peer management
2139    pub(crate) peers: HashMap<SocketAddr, PeerState>,
2140    /// Per-(SocketAddr × torrent) cumulative time we had each peer
2141    /// unchoked. Survives reconnects: when a `PeerState` is dropped on
2142    /// disconnect, its `unchoke_duration_total` is flushed into this map
2143    /// keyed by the peer's `SocketAddr`. Reads via
2144    /// `SessionHandle::peer_unchoke_durations` sum the persistent value
2145    /// here with each currently-live `PeerState`'s in-flight accumulator.
2146    /// Used by the libtorrent-mirror `optimistic_unchoke_fairness` perf
2147    /// scenario to assert the choker rotates upload slots fairly.
2148    pub(crate) unchoke_durations: HashMap<SocketAddr, Duration>,
2149    /// Cached peer download rates for piece stealing decisions.
2150    /// Refreshed on each periodic tick (~1s) instead of rebuilding per block.
2151    pub(crate) cached_peer_rates: FxHashMap<SocketAddr, f64>,
2152    /// Notify handle for reactive queue refill (legacy, unused in M73).
2153    #[allow(dead_code)]
2154    pub(crate) refill_notify: Arc<tokio::sync::Notify>,
2155    /// M93: Lock-free piece states (shared with peers via Arc).
2156    pub(crate) atomic_states: Option<Arc<crate::piece_reservation::AtomicPieceStates>>,
2157    /// M103: Shared block-level request/received bitmaps.
2158    pub(crate) block_maps: Option<Arc<BlockMaps>>,
2159    /// M103: Shared queue of stealable pieces.
2160    pub(crate) steal_candidates: Option<Arc<StealCandidates>>,
2161    /// M132: Last time we populated the steal queue with in-flight pieces.
2162    pub(crate) last_steal_populate: Instant,
2163    /// M120: Per-piece write guards to prevent steal/write races.
2164    pub(crate) piece_write_guards: Option<Arc<crate::piece_reservation::PieceWriteGuards>>,
2165    /// v0.173.3: Reusable buffer for `soft_reap_candidates` output.
2166    /// Reaped every `soft_reap_interval` tick (~1/s) with typical size
2167    /// 0-16 entries; held here so the allocation is reused across ticks.
2168    pub(crate) soft_reap_buf: Vec<std::net::SocketAddr>,
2169    /// v0.187.3 / 3A: sliding-window of recent proactive eviction timestamps.
2170    /// Entries older than 60s are pruned on each tick; the actor refuses to
2171    /// evict when the post-prune length is >= `proactive_evictions_per_minute_limit`.
2172    /// Prevents the 130 → 20-50 churn observed in the dogfood report.
2173    pub(crate) eviction_history: std::collections::VecDeque<std::time::Instant>,
2174    /// v0.187.3 / Bug 8a: when set, the main loop runs the choker on the
2175    /// next iteration regardless of `unchoke_interval`. Set on transition
2176    /// INTO `Seeding` so interested peers see Unchoke promptly. Cleared
2177    /// after the next `run_choker().await`.
2178    pub(crate) force_immediate_choker_tick: bool,
2179    /// M187: Actor-owned piece dispatch tracker (direct-acquire model).
2180    pub(crate) piece_tracker: Option<PieceTracker>,
2181    /// M187: Watch sender that broadcasts `PieceOrderMap` to peer tasks.
2182    pub(crate) order_map_tx: tokio::sync::watch::Sender<Arc<PieceOrderMap>>,
2183
2184    /// M246: set by the `SetFilePriority` arm to request a coalesced order-map
2185    /// rebuild on the next 1 s pipeline tick — a batch of M priority changes
2186    /// sets it M times but triggers ONE rebuild. Cleared by
2187    /// `rebuild_order_map_now`.
2188    pub(crate) order_map_dirty: bool,
2189
2190    /// M246: actor-owned monotone generation counter for the published
2191    /// `PieceOrderMap`. Every rebuild assigns `next_order_map_gen += 1`; the
2192    /// generation is NEVER derived from the published `watch` value — under any
2193    /// async publish that would let two back-to-back batch rebuilds read the
2194    /// same generation and drop one (the rejected-Candidate-H bug class).
2195    pub(crate) next_order_map_gen: u64,
2196    /// M93: Maps piece index -> peer slab slot that owns it.
2197    pub(crate) piece_owner: Vec<Option<u16>>,
2198    /// M93: Arena-allocated peer tracking: slot <-> `SocketAddr`.
2199    pub(crate) peer_slab: crate::piece_reservation::PeerSlab,
2200    #[allow(dead_code)]
2201    pub(crate) priority_pieces: BTreeSet<u32>,
2202    /// M93: Maximum in-flight pieces.
2203    pub(crate) max_in_flight: usize,
2204    /// Piece notify handle (for driver spawning).
2205    pub(crate) reservation_notify: Option<Arc<tokio::sync::Notify>>,
2206    /// Dispatch state snapshot at the previous pipeline tick:
2207    /// `(queue_pieces.count_ones(), inflight.len())`. The tick uses this to
2208    /// gate its `notify_waiters()` safety-net call — if neither value has
2209    /// changed, no peer needs waking, and waking all of them would just
2210    /// trigger spurious acquire calls (the 93% `NoneAvailable` rate measured
2211    /// on 2026-05-11 was largely driven by unconditional 1 Hz wake spam).
2212    /// `None` until the first tick records a baseline.
2213    pub(crate) last_tick_dispatch_state: Option<(u32, usize)>,
2214    pub(crate) choker: Choker,
2215    /// M159: User-requested seed-only mode flag.
2216    ///
2217    /// When `true`, the actor stops issuing new block requests (gating the
2218    /// `StartRequesting` dispatch sent to peer tasks) and cancels any
2219    /// in-flight requests. Uploads continue unaffected. Distinct from the
2220    /// naturally-complete seeding state tracked by `state == Seeding`.
2221    pub(crate) user_seed_mode: bool,
2222    /// Whether the user force-started this torrent (bypassing queue limits).
2223    pub(crate) user_forced: bool,
2224    /// Per-torrent connection limit override (0 = use `config.max_peers`).
2225    pub(crate) max_connections: usize,
2226    /// M137: Unified peer lifecycle tracker (replaces `peers_connected` + `connect_backoff` + `peer_tx` + `unique_peers_attempted`).
2227    pub(crate) peer_states: Option<Arc<crate::peer_states::PeerStates>>,
2228    /// M147: `ConnectPool` semaphore — gates connection attempts only.
2229    /// Permits are released on `HandshakeComplete` (not held for peer lifetime).
2230    pub(crate) connect_semaphore: Arc<tokio::sync::Semaphore>,
2231    /// M147: Maps peer address → permit holder. Permits are taken on `HandshakeComplete`
2232    /// to free `ConnectPool` slots. RAII cleanup on task exit handles failure cases.
2233    pub(crate) connect_permits:
2234        HashMap<SocketAddr, Arc<parking_lot::Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>>,
2235    /// M107: Receiver for connect requests from the adder task.
2236    pub(crate) connect_rx: Option<mpsc::Receiver<ConnectPeer>>,
2237
2238    // Metadata (for magnet links)
2239    pub(crate) metadata_downloader: Option<MetadataDownloader>,
2240
2241    // Parsed torrent meta (for piece hash verification)
2242    pub(crate) meta: Option<TorrentMetaV1>,
2243
2244    /// M116: Pre-computed file->piece mapping for zero-alloc completion checks.
2245    pub(crate) cached_files: Option<CachedFileInfo>,
2246
2247    // Stats
2248    pub(crate) downloaded: u64,
2249    pub(crate) uploaded: u64,
2250    pub(crate) checking_progress: f32,
2251    pub(crate) total_download: u64,
2252    pub(crate) total_upload: u64,
2253    pub(crate) total_failed_bytes: u64,
2254    pub(crate) total_redundant_bytes: u64,
2255    pub(crate) added_time: i64,
2256    pub(crate) completed_time: i64,
2257    pub(crate) last_download: i64,
2258    pub(crate) last_upload: i64,
2259    pub(crate) last_seen_complete: i64,
2260    pub(crate) active_duration: i64,
2261    pub(crate) finished_duration: i64,
2262    pub(crate) seeding_duration: i64,
2263    pub(crate) active_since: Option<std::time::Instant>,
2264    pub(crate) state_duration_since: Option<std::time::Instant>,
2265    #[allow(dead_code)] // M104: ConnectPhase removed; kept for future diagnostics
2266    pub(crate) started_at: std::time::Instant,
2267    pub(crate) moving_storage: bool,
2268    pub(crate) has_incoming: bool,
2269    pub(crate) need_save_resume: bool,
2270    pub(crate) error: String,
2271    pub(crate) error_file: i32,
2272
2273    // Channels
2274    pub(crate) cmd_rx: mpsc::Receiver<TorrentCommand>,
2275    pub(crate) event_tx: mpsc::Sender<PeerEvent>,
2276    pub(crate) event_rx: mpsc::Receiver<PeerEvent>,
2277
2278    // Async disk pipeline channels
2279    pub(crate) write_error_rx: mpsc::Receiver<crate::disk::DiskWriteError>,
2280    pub(crate) write_error_tx: mpsc::Sender<crate::disk::DiskWriteError>,
2281    pub(crate) verify_result_rx: mpsc::Receiver<crate::disk::VerifyResult>,
2282    pub(crate) verify_result_tx: mpsc::Sender<crate::disk::VerifyResult>,
2283    /// Pieces currently awaiting async verification — prevents duplicate
2284    /// verify tasks when end game or slow peers deliver duplicate blocks.
2285    pub(crate) pending_verify: HashSet<u32>,
2286    /// Generation counter per piece — increments on release/re-reserve.
2287    /// Used to detect stale hash results from the `HashPool` (M96).
2288    pub(crate) piece_generations: Vec<u64>,
2289    /// Receiver for hash pool results (M96).
2290    pub(crate) hash_result_rx: tokio::sync::mpsc::Receiver<crate::hash_pool::HashResult>,
2291    /// Sender for hash pool results — cloned into `DiskHandle` (M96).
2292    pub(crate) hash_result_tx: tokio::sync::mpsc::Sender<crate::hash_pool::HashResult>,
2293
2294    // TCP listener for incoming peer connections
2295    pub(crate) listener: Option<Box<dyn crate::transport::TransportListener>>,
2296
2297    // uTP socket for outbound connections (shared with session, cloned)
2298    pub(crate) utp_socket: Option<irontide_utp::UtpSocket>,
2299    // IPv6 uTP socket for outbound connections to IPv6 peers
2300    pub(crate) utp_socket_v6: Option<irontide_utp::UtpSocket>,
2301
2302    // Tracker management
2303    pub(crate) tracker_manager: TrackerManager,
2304    /// M143: Receiver for streaming tracker announce results.
2305    /// `Some` while a background announce is in-flight, `None` when idle.
2306    pub(crate) tracker_result_rx: Option<mpsc::Receiver<crate::tracker_manager::TrackerPeerBatch>>,
2307
2308    // DHT handles (shared, optional). M173 Lane B (B6): subscribe to
2309    // the session-level DhtBroadcast so a runtime DHT restart is
2310    // observed on the next call without holding a stale clone. The
2311    // `dht_enabled` flag gates BEP 27 private torrents (which must
2312    // not announce to DHT regardless of session-level enable) plus
2313    // any per-torrent opt-out.
2314    pub(crate) dht_rx: irontide_dht::DhtReceiver,
2315    pub(crate) dht_v6_rx: irontide_dht::DhtReceiver,
2316    pub(crate) dht_enabled: bool,
2317    pub(crate) dht_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2318    pub(crate) dht_v6_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2319    /// Consecutive times the V6 DHT returned an empty table.
2320    /// After 30 failures (~3s at 100ms), stop retrying to avoid log spam.
2321    pub(crate) dht_v6_empty_count: u32,
2322    /// Timestamp of last V6 DHT retry attempt (M97).
2323    pub(crate) dht_v6_last_retry: Option<std::time::Instant>,
2324
2325    // Alert system (M15)
2326    pub(crate) alert_tx: broadcast::Sender<Alert>,
2327    pub(crate) alert_mask: Arc<AtomicU32>,
2328
2329    // Rate limiting (M14)
2330    pub(crate) upload_bucket: crate::rate_limiter::TokenBucket,
2331    pub(crate) download_bucket: SharedBucket,
2332    pub(crate) global_upload_bucket: Option<SharedBucket>,
2333    #[allow(dead_code)] // M73: rate limiting deferred to M74
2334    pub(crate) global_download_bucket: Option<SharedBucket>,
2335    pub(crate) slot_tuner: crate::slot_tuner::SlotTuner,
2336    pub(crate) upload_bytes_interval: u64,
2337
2338    /// Peak aggregate download rate observed (bytes/sec), for peer turnover cutoff.
2339    pub(crate) peak_download_rate: u64,
2340
2341    // Web seeding (M22)
2342    pub(crate) web_seeds: HashMap<String, mpsc::Sender<crate::web_seed::WebSeedCommand>>,
2343    pub(crate) banned_web_seeds: HashSet<String>,
2344    pub(crate) web_seed_in_flight: HashMap<u32, String>,
2345    /// M178: Per-URL stats accumulated from `PeerEvent::WebSeedProgress`
2346    /// events emitted by `WebSeedTask`. Persisted to fast-resume so stats
2347    /// survive app restart (see `resume_file.rs`).
2348    pub(crate) web_seed_stats: HashMap<String, irontide_core::WebSeedStats>,
2349    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2350    /// via PEX (BEP 11) since this actor started. Surfaced through
2351    /// `SessionHandle::pex_peer_count` for the qBt v2 trackers endpoint
2352    /// and the GUI Trackers tab pseudo-tracker rows. Reset on torrent
2353    /// removal (actor lifecycle).
2354    pub(crate) pex_peer_count: usize,
2355    /// M178 (Lane B3 / TODO-2): cumulative count of UNIQUE peers received
2356    /// via LSD (BEP 14) multicast. Self-cookie filtering happens upstream
2357    /// in M174's session-level LSD path.
2358    pub(crate) lsd_peer_count: usize,
2359
2360    // BEP 16 super seeding (M23)
2361    pub(crate) super_seed: Option<crate::super_seed::SuperSeedState>,
2362    // M118: Broadcast channel for Have distribution (replaces HaveBuffer)
2363    pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender<u32>,
2364
2365    /// M44: pieces we've suggested to each peer (avoid re-suggesting)
2366    pub(crate) suggested_to_peers: HashMap<SocketAddr, HashSet<u32>>,
2367
2368    /// M44: pieces for which we've already sent predictive Have
2369    pub(crate) predictive_have_sent: HashSet<u32>,
2370
2371    // Smart banning (M25)
2372    pub(crate) ban_manager: irontide_session_types::SharedBanManager,
2373    pub(crate) piece_contributors: HashMap<u32, HashSet<std::net::IpAddr>>,
2374    pub(crate) parole_pieces: HashMap<u32, crate::ban::ParoleState>,
2375
2376    // IP filtering (M29)
2377    pub(crate) ip_filter: irontide_session_types::SharedIpFilter,
2378
2379    // BEP 40 peer priority (M32b)
2380    pub(crate) external_ip: Option<std::net::IpAddr>,
2381
2382    // Share mode (M32c): LRU tracker for in-memory piece relay.
2383    // Tracks which pieces are currently "live" (servable) in share mode.
2384    // Oldest pieces are evicted when capacity is reached.
2385    pub(crate) share_lru: std::collections::VecDeque<u32>,
2386    /// Max pieces to keep live in share mode (0 = share mode disabled).
2387    pub(crate) share_max_pieces: usize,
2388
2389    // Extension plugins (M32d)
2390    pub(crate) plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
2391
2392    // BEP 52 v2/hybrid support (M34-M35)
2393    pub(crate) hash_picker: Option<irontide_core::HashPicker>,
2394    pub(crate) version: irontide_core::TorrentVersion,
2395    #[allow(dead_code)] // stored for hybrid torrent re-serialization (M35 Task 5)
2396    pub(crate) meta_v2: Option<irontide_core::TorrentMetaV2>,
2397
2398    /// Full info hashes for dual-swarm support (v1 + v2 for hybrid).
2399    pub(crate) info_hashes: irontide_core::InfoHashes,
2400
2401    /// Dual-swarm DHT peer receivers (v2 hash in hybrid torrents).
2402    pub(crate) dht_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2403    pub(crate) dht_v6_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
2404
2405    /// BEP 53: deferred file selection from magnet `so=` parameter.
2406    /// Applied after metadata is received to set file priorities.
2407    pub(crate) magnet_selected_files: Option<Vec<irontide_core::FileSelection>>,
2408
2409    /// I2P SAM session for anonymous peer connections (M41).
2410    pub(crate) sam_session: Option<Arc<crate::i2p::SamSession>>,
2411
2412    /// Receiver for incoming I2P peer connections (M41).
2413    pub(crate) i2p_accept_rx: Option<mpsc::Receiver<crate::i2p::SamStream>>,
2414
2415    /// Counter for generating synthetic `SocketAddr` values for I2P peers (M41).
2416    pub(crate) i2p_peer_counter: u32,
2417
2418    /// Maps synthetic `SocketAddr` → `I2pDestination` for outbound I2P connects.
2419    pub(crate) i2p_destinations: HashMap<SocketAddr, crate::i2p::I2pDestination>,
2420
2421    /// SSL manager for SSL torrent certificate handling (M42).
2422    pub(crate) ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
2423
2424    /// Per-class rate limiting with mixed-mode (M45).
2425    pub(crate) rate_limiter_set: crate::rate_limiter::RateLimiterSet,
2426    /// Whether auto-sequential mode is currently active (hysteresis state).
2427    pub(crate) auto_sequential_active: bool,
2428    /// Network transport factory for TCP operations (M51).
2429    pub(crate) factory: Arc<crate::transport::NetworkFactory>,
2430    /// Shared hash pool for parallel piece verification (M96).
2431    pub(crate) hash_pool_ref: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
2432    /// M108: Shared snapshot of connected peer addresses for PEX send tasks.
2433    pub(crate) live_outgoing_peers:
2434        std::sync::Arc<parking_lot::RwLock<std::collections::HashMap<SocketAddr, u8>>>,
2435    /// M108: Total outbound connection attempts dispatched to peer adder.
2436    pub(crate) connect_attempts: u64,
2437    /// M108: Total connection failures (peers that disconnected).
2438    pub(crate) connect_failures: u64,
2439    /// M138: Total number of peers evicted by proactive choke rotation.
2440    pub(crate) choke_rotations: u64,
2441    /// M149: When each in-flight piece started downloading. Indexed by piece index.
2442    /// Set when `piece_owner` assigns a piece, cleared on verify/hash-fail.
2443    pub(crate) inflight_started: Vec<Option<Instant>>,
2444    /// M149: Rolling window of recent piece completion times for steal threshold.
2445    pub(crate) completed_piece_times: std::collections::VecDeque<Duration>,
2446    /// M149: Total number of piece-level steals performed.
2447    pub(crate) piece_steals: u64,
2448    /// M190: Total holepunch rendezvous requests we relayed.
2449    pub(crate) holepunch_relayed: u64,
2450    /// M190: Per-peer rate limit for holepunch rendezvous requests.
2451    pub(crate) holepunch_relay_rate: HashMap<SocketAddr, (Instant, u32)>,
2452    /// M112: Tracks recent holepunch attempts to prevent retry storms.
2453    pub(crate) holepunch_cooldowns: HashMap<SocketAddr, Instant>,
2454    /// M112: Buffer for holepunch attempts (`disconnect_peer` is sync, `try_holepunch` is async).
2455    pub(crate) holepunch_pending: Vec<SocketAddr>,
2456    /// Sim-perf engine surface: shared session counters used by
2457    /// `rebuild_availability_snapshot` to track Allow / Defer rates,
2458    /// and by per-peer spawn sites to seed `PeerShared::counters`.
2459    pub(crate) counters: Arc<crate::stats::SessionCounters>,
2460}
2461
2462/// Maximum number of in-flight end-game requests per peer.
2463/// libtorrent continues full pipelining in end-game; we use a moderate
2464/// depth so that round-trip latency doesn't bottleneck throughput.
2465/// End-game pipeline depth: match normal mode (128 slots per peer).
2466/// Safe because the reactive per-block cascade was replaced with a 200ms
2467/// batch refill tick — raising depth no longer amplifies picker invocations.
2468pub(crate) const END_GAME_DEPTH: usize = 128;
2469
2470/// Minimum free pipeline slots before invoking the full piece picker in
2471/// `handle_piece_data()`.  Avoids running the 5-layer picker on every single
2472impl TorrentActor {
2473    /// Returns the current IPv4 `DhtHandle`, or `None` if DHT is disabled
2474    /// for this torrent (BEP 27 private, per-torrent opt-out, or
2475    /// session-level disable). M173 Lane B (B6): reads from the
2476    /// session-level [`irontide_dht::DhtBroadcast`] receiver, so a
2477    /// runtime DHT restart (B11) is observed transparently here.
2478    pub(crate) fn current_dht(&self) -> Option<irontide_dht::DhtHandle> {
2479        if self.dht_enabled {
2480            self.dht_rx.current()
2481        } else {
2482            None
2483        }
2484    }
2485
2486    /// Returns the current IPv6 `DhtHandle`, or `None` if DHT is disabled.
2487    /// See [`Self::current_dht`].
2488    pub(crate) fn current_dht_v6(&self) -> Option<irontide_dht::DhtHandle> {
2489        if self.dht_enabled {
2490            self.dht_v6_rx.current()
2491        } else {
2492            None
2493        }
2494    }
2495
2496    /// Hold-window helper: wait up to `hold` for the IPv4 DHT broadcast
2497    /// to deliver a non-`None` handle. Returns `None` if the wait
2498    /// times out OR if DHT is disabled for this torrent.
2499    ///
2500    /// Used by call sites that issue requests during a DHT restart
2501    /// window and prefer to hold a brief moment rather than fail
2502    /// immediately. The hold is bounded — callers must not loop on
2503    /// `None`, since a permanently-disabled DHT will hit the timeout
2504    /// every iteration.
2505    ///
2506    /// # Errors
2507    ///
2508    /// Returns `None` on timeout, on disabled DHT, or if the
2509    /// broadcast sender has been dropped (session shutting down).
2510    #[allow(dead_code)] // wired by future per-call-site refactors as needed
2511    pub(crate) async fn current_dht_or_wait(
2512        &mut self,
2513        hold: std::time::Duration,
2514    ) -> Option<irontide_dht::DhtHandle> {
2515        if !self.dht_enabled {
2516            return None;
2517        }
2518        if let Some(handle) = self.dht_rx.current() {
2519            return Some(handle);
2520        }
2521        // Wait for the broadcast to fire `replace(Some(_))`.
2522        match tokio::time::timeout(hold, self.dht_rx.changed()).await {
2523            Ok(Ok(())) => self.dht_rx.current(),
2524            Ok(Err(_)) | Err(_) => None,
2525        }
2526    }
2527
2528    /// Main event loop.
2529    async fn run(mut self) {
2530        // Verify existing pieces on startup (resume support)
2531        self.verify_existing_pieces().await;
2532
2533        // M93: Initialize lock-free piece states after verification
2534        // so we_have reflects already-verified pieces.
2535        if let Some(ct) = &self.chunk_tracker {
2536            let atomic_states = Arc::new(AtomicPieceStates::new(
2537                self.num_pieces,
2538                ct.bitfield(),
2539                &self.wanted_pieces,
2540            ));
2541            self.atomic_states = Some(Arc::clone(&atomic_states));
2542            self.piece_owner = vec![None; self.num_pieces as usize];
2543            // M149: Initialize inflight tracking
2544            self.inflight_started = vec![None; self.num_pieces as usize];
2545            self.max_in_flight = self.config.max_in_flight_pieces;
2546
2547            // M103: Initialize block stealing infrastructure
2548            if self.config.use_block_stealing {
2549                if let Some(ref lengths) = self.lengths {
2550                    self.block_maps = Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
2551                }
2552                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
2553            }
2554            // M120: Per-piece write guards
2555            self.piece_write_guards = Some(Arc::new(
2556                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
2557            ));
2558
2559            // M187: Init direct-acquire dispatch state.
2560            self.piece_tracker = Some(PieceTracker::new(
2561                self.num_pieces,
2562                ct.bitfield(),
2563                &self.wanted_pieces,
2564            ));
2565            if let Some(ref cached) = self.cached_files {
2566                let file_piece_ranges: Vec<(u32, u32)> = cached
2567                    .entries
2568                    .iter()
2569                    .map(|e| (e.first_piece, e.last_piece))
2570                    .collect();
2571                let om = Arc::new(PieceOrderMap::build(
2572                    &self.file_priorities,
2573                    &file_piece_ranges,
2574                    self.num_pieces,
2575                    0,
2576                ));
2577                self.order_map_tx.send_replace(om);
2578            }
2579
2580            let notify = Arc::new(tokio::sync::Notify::new());
2581            self.reservation_notify = Some(notify);
2582        }
2583
2584        // Spawn web seeds if not already seeding
2585        if self.state != TorrentState::Seeding {
2586            self.spawn_web_seeds();
2587            self.assign_pieces_to_web_seeds();
2588        }
2589
2590        // M147: Set up ConnectPool — semaphore gates connection attempts only.
2591        // Permits are released on HandshakeComplete, not held for peer lifetime.
2592        let connect_semaphore = Arc::new(tokio::sync::Semaphore::new(
2593            self.effective_max_connections(),
2594        ));
2595        self.connect_semaphore = Arc::clone(&connect_semaphore);
2596        self.connect_permits.clear();
2597        // M137: Create PeerStates with the adder's input channel.
2598        // v0.187.3: pull eviction-ban cap + duration from session settings so
2599        // user changes via apply_settings take effect on the next spawn.
2600        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
2601        let peer_states = Arc::new(crate::peer_states::PeerStates::new_with_config(
2602            queue_tx,
2603            self.config.eviction_ban_set_cap,
2604            std::time::Duration::from_secs(self.config.eviction_ban_duration_secs),
2605        ));
2606        self.peer_states = Some(Arc::clone(&peer_states));
2607        let (adder_connect_tx, adder_connect_rx) = mpsc::channel(64);
2608        self.connect_rx = Some(adder_connect_rx);
2609        // M147: ConnectPool semaphore gates connection attempts (released on handshake)
2610        tokio::spawn(peer_adder::peer_adder_task(
2611            queue_rx,
2612            Arc::clone(&connect_semaphore),
2613            Arc::clone(&peer_states),
2614            Arc::clone(&self.ban_manager),
2615            Arc::clone(&self.ip_filter),
2616            adder_connect_tx,
2617        ));
2618
2619        let mut unchoke_interval = tokio::time::interval(Duration::from_secs(10));
2620        let mut rate_interval = tokio::time::interval(Duration::from_secs(2));
2621        rate_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2622        let mut optimistic_interval = tokio::time::interval(Duration::from_secs(30));
2623        let mut refill_interval = tokio::time::interval(Duration::from_millis(100));
2624        let mut dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::ZERO));
2625        let mut suggest_interval = if self.config.suggest_mode {
2626            Some(tokio::time::interval(Duration::from_secs(30)))
2627        } else {
2628            None
2629        };
2630        // M136: 1s steal-queue maintenance tick.
2631        let mut turnover_interval = tokio::time::interval(Duration::from_secs(1));
2632        let mut pipeline_tick_interval = tokio::time::interval(Duration::from_secs(1));
2633        // M77: Skip missed ticks — safety-net notify should fire at most once/second
2634        pipeline_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2635        let mut end_game_tick_interval = tokio::time::interval(Duration::from_millis(200));
2636        end_game_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2637        let mut diag_interval = tokio::time::interval(Duration::from_secs(5));
2638        // M108: 30s connection success rate summary for variance diagnosis.
2639        let mut conn_stats_interval = tokio::time::interval(Duration::from_secs(30));
2640        // M107: 5s metadata piece timeout — only meaningful in FetchingMetadata state
2641        let mut metadata_timeout_interval = tokio::time::interval(Duration::from_secs(5));
2642        // M103: 50ms debounce for reactive snapshot (was 500ms fixed interval)
2643        // M147: 1s soft reap interval — disconnects connecting peers without TCP SYN-ACK
2644        let mut soft_reap_interval = tokio::time::interval(Duration::from_secs(1));
2645        // M148: 2s proactive eviction — breaks the catch-22 where LivePool fills with
2646        // deadweight and no HandshakeComplete events arrive to trigger eviction.
2647        let mut eviction_interval = tokio::time::interval(Duration::from_secs(2));
2648        eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2649
2650        // Don't fire immediately for the first tick
2651        unchoke_interval.tick().await;
2652        optimistic_interval.tick().await;
2653        refill_interval.tick().await;
2654        // Note: dht_requery_sleep uses Sleep (not Interval), no initial tick skip needed
2655        if let Some(ref mut si) = suggest_interval {
2656            si.tick().await; // skip initial tick
2657        }
2658        turnover_interval.tick().await;
2659        pipeline_tick_interval.tick().await;
2660        end_game_tick_interval.tick().await;
2661        diag_interval.tick().await;
2662        conn_stats_interval.tick().await;
2663        metadata_timeout_interval.tick().await;
2664        soft_reap_interval.tick().await;
2665        eviction_interval.tick().await;
2666
2667        // Initial tracker announce (Started event) — non-blocking, fires via select! arm
2668        // DHT announce (v4 + v6) — dual-swarm for hybrid torrents
2669        if self.state == TorrentState::Downloading && self.config.enable_dht {
2670            // Primary hash (v1 or best_v1)
2671            if let Some(dht) = self.current_dht()
2672                && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
2673            {
2674                warn!("DHT v4 announce failed: {e}");
2675            }
2676            if let Some(dht6) = self.current_dht_v6()
2677                && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
2678            {
2679                debug!("DHT v6 announce failed: {e}");
2680            }
2681            // Dual-swarm: also announce v2 hash (truncated) for hybrid torrents
2682            if self.info_hashes.is_hybrid()
2683                && let Some(v2) = self.info_hashes.v2
2684            {
2685                let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
2686                if v2_as_v1 != self.info_hash {
2687                    if let Some(dht) = self.current_dht()
2688                        && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
2689                    {
2690                        debug!("DHT v4 dual-swarm announce failed: {e}");
2691                    }
2692                    if let Some(dht6) = self.current_dht_v6()
2693                        && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
2694                    {
2695                        debug!("DHT v6 dual-swarm announce failed: {e}");
2696                    }
2697                }
2698            }
2699        }
2700
2701        // I2P accept loop: spawn a background task that feeds incoming I2P
2702        // connections back via a channel, so the select! arm can handle them.
2703        if self.config.enable_i2p
2704            && let Some(ref sam) = self.sam_session
2705        {
2706            let (tx, rx) = mpsc::channel(16);
2707            let sam = Arc::clone(sam);
2708            tokio::spawn(async move {
2709                loop {
2710                    match sam.accept().await {
2711                        Ok(stream) => {
2712                            if tx.send(stream).await.is_err() {
2713                                break; // torrent actor dropped
2714                            }
2715                        }
2716                        Err(e) => {
2717                            warn!("I2P accept error: {e}");
2718                            tokio::time::sleep(Duration::from_secs(5)).await;
2719                        }
2720                    }
2721                }
2722            });
2723            self.i2p_accept_rx = Some(rx);
2724        }
2725
2726        loop {
2727            tokio::select! {
2728                biased;
2729                // Events from peers — batch-drain to reduce select! overhead.
2730                // At 100 MB/s we get ~6K events/sec; processing one-by-one
2731                // means 6K select! iterations with waker re-registration.
2732                // biased; ensures this high-throughput arm is checked first.
2733                event = self.event_rx.recv() => {
2734                    if let Some(event) = event {
2735                        // M182: ping the per-peer event_drain Notify so
2736                        // the reader's BackpressureQueue retries any
2737                        // spilled events. Looked up from peers by
2738                        // event.peer_addr (most variants carry one);
2739                        // events without a peer (PexPeers,
2740                        // TrackersReceived, WebSeed*) skip the ping —
2741                        // they don't fill the per-peer event channel.
2742                        Self::ping_event_drain(&self.peers, &event);
2743                        self.handle_peer_event(event)
2744                            .instrument(tracing::debug_span!("handle_peer_event"))
2745                            .await;
2746                        // Drain up to 512 more ready events without re-entering select!
2747                        for _ in 0..512 {
2748                            match self.event_rx.try_recv() {
2749                                Ok(event) => {
2750                                    Self::ping_event_drain(&self.peers, &event);
2751                                    self.handle_peer_event(event).await;
2752                                }
2753                                Err(_) => break,
2754                            }
2755                        }
2756                    }
2757                }
2758                // Async piece verification results
2759                Some(result) = self.verify_result_rx.recv() => {
2760                    self.pending_verify.remove(&result.piece);
2761                    // Guard: ignore stale/duplicate results for already-verified pieces
2762                    let dominated = self.chunk_tracker.as_ref()
2763                        .is_some_and(|ct| ct.bitfield().get(result.piece));
2764                    if !dominated {
2765                        if result.passed {
2766                            self.on_piece_verified(result.piece).await;
2767                        } else {
2768                            self.on_piece_hash_failed(result.piece).await;
2769                            // M73: Drivers pick up released pieces automatically via shared state
2770                        }
2771                    }
2772                }
2773                // M96: Hash pool verification results
2774                Some(result) = self.hash_result_rx.recv() => {
2775                    self.handle_hash_result(result).await;
2776                }
2777                // Commands from handle
2778                cmd = self.cmd_rx.recv() => {
2779                    match cmd {
2780                        Some(TorrentCommand::AddPeers { peers, source }) => {
2781                            self.handle_add_peers(peers, source);
2782                        }
2783                        Some(TorrentCommand::Stats { reply }) => {
2784                            let _ = reply.send(self.make_stats());
2785                        }
2786                        Some(TorrentCommand::Pause) => {
2787                            self.handle_pause().await;
2788                        }
2789                        Some(TorrentCommand::Queue) => {
2790                            self.handle_queue();
2791                        }
2792                        Some(TorrentCommand::Resume) => {
2793                            self.handle_resume().await;
2794                        }
2795                        Some(TorrentCommand::ForceResume) => {
2796                            self.user_forced = true;
2797                            self.handle_resume().await;
2798                        }
2799                        Some(TorrentCommand::SetCategory { category, reply }) => {
2800                            // M170: update the per-torrent category label.
2801                            // Marks resume as dirty so the next periodic
2802                            // save captures it.
2803                            self.config.category = category;
2804                            self.need_save_resume = true;
2805                            let _ = reply.send(());
2806                        }
2807                        Some(TorrentCommand::SetTags { tags, reply }) => {
2808                            // M171: replace the per-torrent tag set. Marks
2809                            // resume as dirty so the next periodic save
2810                            // captures it. `make_stats()` reads
2811                            // `self.config.tags` directly so the change is
2812                            // immediately visible to the next `stats()`
2813                            // call.
2814                            self.config.tags = tags;
2815                            self.need_save_resume = true;
2816                            let _ = reply.send(());
2817                        }
2818                        Some(TorrentCommand::GetWebSeeds { reply }) => {
2819                            // M171 Lane B: union of BEP 19 `url-list` and
2820                            // BEP 17 `httpseeds`. Order: BEP 19 first, then
2821                            // BEP 17 — matches the wire order. Returns an
2822                            // empty vec when metadata hasn't resolved yet
2823                            // (magnet still fetching info dict).
2824                            let urls = match &self.meta {
2825                                Some(meta) => {
2826                                    let mut v = Vec::with_capacity(
2827                                        meta.url_list.len() + meta.httpseeds.len(),
2828                                    );
2829                                    v.extend(meta.url_list.iter().cloned());
2830                                    v.extend(meta.httpseeds.iter().cloned());
2831                                    v
2832                                }
2833                                None => Vec::new(),
2834                            };
2835                            let _ = reply.send(urls);
2836                        }
2837                        Some(TorrentCommand::GetPieceStates { reply }) => {
2838                            // M171 Lane B: snapshot per-piece state as qBt
2839                            // codes. Returns an empty vec when metadata
2840                            // hasn't resolved (piece count unknown).
2841                            let states = match self.atomic_states.as_ref() {
2842                                Some(atomic) => atomic.snapshot(),
2843                                None => Vec::new(),
2844                            };
2845                            let _ = reply.send(states);
2846                        }
2847                        Some(TorrentCommand::GetPieceHashes { offset, limit, reply }) => {
2848                            // M171 Lane B: v1 piece hashes live in
2849                            // `meta.info.pieces` (20-byte SHA-1 concat);
2850                            // v2-only piece hashes live in
2851                            // `meta_v2.piece_layers` values (32-byte
2852                            // SHA-256 concat). Hybrid prefers v1 because
2853                            // the qBt client ecosystem treats v1 as the
2854                            // canonical hash surface.
2855                            //
2856                            // M245 L3: select the RAW bytes for the requested
2857                            // window ONLY (cheap chunk-slice clones of resident
2858                            // metadata) and hand them back unencoded. The old
2859                            // path hex-encoded EVERY hash on this recv loop and
2860                            // then threw all but the window away; the
2861                            // `hex::encode` now happens in the handle method off
2862                            // the loop. `skip(offset).take(limit)` reproduces the
2863                            // old `[offset, offset+limit)`-clamped-to-len window.
2864                            let offset = offset as usize;
2865                            let limit = limit as usize;
2866                            let raw: Vec<Vec<u8>> = match self.version {
2867                                irontide_core::TorrentVersion::V1Only
2868                                | irontide_core::TorrentVersion::Hybrid => self
2869                                    .meta
2870                                    .as_ref()
2871                                    .map(|meta| {
2872                                        meta.info
2873                                            .pieces
2874                                            .chunks_exact(20)
2875                                            .skip(offset)
2876                                            .take(limit)
2877                                            .map(<[u8]>::to_vec)
2878                                            .collect::<Vec<Vec<u8>>>()
2879                                    })
2880                                    .unwrap_or_default(),
2881                                irontide_core::TorrentVersion::V2Only => self
2882                                    .meta_v2
2883                                    .as_ref()
2884                                    .map(|m| {
2885                                        m.piece_layers
2886                                            .values()
2887                                            .flat_map(|v| v.chunks_exact(32))
2888                                            .skip(offset)
2889                                            .take(limit)
2890                                            .map(<[u8]>::to_vec)
2891                                            .collect::<Vec<Vec<u8>>>()
2892                                    })
2893                                    .unwrap_or_default(),
2894                            };
2895                            let _ = reply.send(raw);
2896                        }
2897                        Some(TorrentCommand::SaveResumeData { reply }) => {
2898                            let result = self.build_resume_data();
2899                            let _ = reply.send(result);
2900                        }
2901                        Some(TorrentCommand::TakeResumeIfDirty { reply }) => {
2902                            // M245 F1 — atomic take. ATOMICITY GUARD: there must
2903                            // be NO `.await` between reading `need_save_resume`
2904                            // and clearing it. The actor processes commands
2905                            // serially, so this whole arm is one indivisible
2906                            // turn — `build_resume_data()` is synchronous (no
2907                            // yield). Any `need_save_resume = true` set by
2908                            // another command lands strictly BEFORE this turn
2909                            // (captured here) or strictly AFTER (preserved for
2910                            // the next cycle); it can never be lost to a clear
2911                            // that races the build, the pre-M245 stats→save→
2912                            // clear three-step bug. The flag is cleared ONLY on
2913                            // a successful build, so a build error keeps the
2914                            // torrent dirty for retry.
2915                            let result = if self.need_save_resume {
2916                                let built = self.build_resume_data();
2917                                if built.is_ok() {
2918                                    self.need_save_resume = false;
2919                                }
2920                                built.map(Some)
2921                            } else {
2922                                Ok(None)
2923                            };
2924                            let _ = reply.send(result);
2925                        }
2926                        Some(TorrentCommand::SetFilePriority { index, priority, reply }) => {
2927                            // M246: range-scope the synchronous correctness-bearing passes
2928                            // (wanted_pieces / atomic_states / piece_tracker — they gate
2929                            // dispatch and must be coherent the instant the reply is sent)
2930                            // to the changed file's pieces, and DEFER the advisory global
2931                            // order-map rebuild to the coalescing 1 s pipeline tick by
2932                            // setting `order_map_dirty`, instead of running four
2933                            // O(num_pieces) passes on the recv loop. A GUI batch of M files
2934                            // (M back-to-back commands) now sets the flag M times but
2935                            // triggers a single rebuild.
2936                            match self.apply_file_priority_scoped(index, priority) {
2937                                Ok((first, last)) => {
2938                                    self.sync_piece_states_for_range(first, last);
2939                                    if let Some(ref mut pt) = self.piece_tracker {
2940                                        for piece in first..=last {
2941                                            if self.wanted_pieces.get(piece) {
2942                                                pt.mark_wanted(piece);
2943                                            } else {
2944                                                pt.mark_unwanted(piece);
2945                                            }
2946                                        }
2947                                    }
2948                                    self.order_map_dirty = true;
2949                                    let _ = reply.send(Ok(()));
2950                                }
2951                                Err(e) => {
2952                                    let _ = reply.send(Err(e));
2953                                }
2954                            }
2955                        }
2956                        Some(TorrentCommand::FilePriorities { reply }) => {
2957                            let _ = reply.send(self.file_priorities.clone());
2958                        }
2959                        Some(TorrentCommand::ForceReannounce) => {
2960                            self.tracker_manager.force_reannounce();
2961                        }
2962                        Some(TorrentCommand::TrackerList { reply }) => {
2963                            let _ = reply.send(self.tracker_manager.tracker_list());
2964                        }
2965                        Some(TorrentCommand::Scrape { reply }) => {
2966                            let result = self.tracker_manager.scrape().await;
2967                            if let Some((ref url, ref info)) = result {
2968                                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::ScrapeReply {
2969                                    info_hash: self.info_hash,
2970                                    url: url.clone(),
2971                                    complete: info.complete,
2972                                    incomplete: info.incomplete,
2973                                    downloaded: info.downloaded,
2974                                });
2975                            }
2976                            let _ = reply.send(result);
2977                        }
2978                        Some(TorrentCommand::OpenFile { file_index, reply }) => {
2979                            let result = self.handle_open_file(file_index);
2980                            let _ = reply.send(result);
2981                        }
2982                        Some(TorrentCommand::IncomingPeer { stream, addr }) => {
2983                            self.spawn_peer_from_stream_with_mode(
2984                                addr,
2985                                stream,
2986                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2987                            );
2988                        }
2989                        Some(TorrentCommand::UpdateExternalIp { ip }) => {
2990                            self.external_ip = Some(ip);
2991                            post_alert(
2992                                &self.alert_tx,
2993                                &self.alert_mask,
2994                                AlertKind::ExternalIpDetected { ip },
2995                            );
2996                        }
2997                        Some(TorrentCommand::MoveStorage { new_path, reply }) => {
2998                            let result = self.handle_move_storage(new_path).await;
2999                            let _ = reply.send(result);
3000                        }
3001                        Some(TorrentCommand::SpawnSslPeer { addr, stream }) => {
3002                            // TLS is already completed; encryption is handled by TLS layer
3003                            self.spawn_peer_from_stream_with_mode(
3004                                addr,
3005                                stream.0,
3006                                Some(irontide_wire::mse::EncryptionMode::Disabled),
3007                            );
3008                        }
3009                        Some(TorrentCommand::SetDownloadLimit { bytes_per_sec, reply }) => {
3010                            self.download_bucket.lock().set_rate(bytes_per_sec);
3011                            let _ = reply.send(());
3012                        }
3013                        Some(TorrentCommand::SetUploadLimit { bytes_per_sec, reply }) => {
3014                            self.upload_bucket.set_rate(bytes_per_sec);
3015                            let _ = reply.send(());
3016                        }
3017                        Some(TorrentCommand::DownloadLimit { reply }) => {
3018                            let _ = reply.send(self.download_bucket.lock().rate());
3019                        }
3020                        Some(TorrentCommand::UploadLimit { reply }) => {
3021                            let _ = reply.send(self.upload_bucket.rate());
3022                        }
3023                        Some(TorrentCommand::SetSequentialDownload { enabled, reply }) => {
3024                            self.config.sequential_download = enabled;
3025                            let _ = reply.send(());
3026                        }
3027                        Some(TorrentCommand::IsSequentialDownload { reply }) => {
3028                            let _ = reply.send(self.config.sequential_download);
3029                        }
3030                        Some(TorrentCommand::SetSuperSeeding { enabled, reply }) => {
3031                            self.config.super_seeding = enabled;
3032                            self.super_seed = if enabled {
3033                                Some(crate::super_seed::SuperSeedState::new())
3034                            } else {
3035                                None
3036                            };
3037                            let _ = reply.send(());
3038                        }
3039                        Some(TorrentCommand::IsSuperSeeding { reply }) => {
3040                            let _ = reply.send(self.config.super_seeding);
3041                        }
3042                        Some(TorrentCommand::SetSeedMode { enabled, reply }) => {
3043                            self.handle_set_seed_mode(enabled);
3044                            let _ = reply.send(());
3045                        }
3046                        Some(TorrentCommand::SetSeedRatioLimit { limit, reply }) => {
3047                            self.config.seed_ratio_limit = limit;
3048                            self.need_save_resume = true;
3049                            let _ = reply.send(());
3050                        }
3051                        Some(TorrentCommand::AddTracker { url }) => {
3052                            self.tracker_manager.add_tracker_url(&url);
3053                        }
3054                        Some(TorrentCommand::ReplaceTrackers { urls, reply }) => {
3055                            self.tracker_manager.replace_all(&urls);
3056                            let _ = reply.send(());
3057                        }
3058                        Some(TorrentCommand::ForceRecheck { reply }) => {
3059                            self.handle_force_recheck(reply).await;
3060                        }
3061                        Some(TorrentCommand::RenameFile { file_index, new_name, reply }) => {
3062                            let result = self.handle_rename_file(file_index, new_name).await;
3063                            let _ = reply.send(result);
3064                        }
3065                        Some(TorrentCommand::SetMaxConnections { limit, reply }) => {
3066                            self.max_connections = limit;
3067                            let _ = reply.send(());
3068                        }
3069                        Some(TorrentCommand::MaxConnections { reply }) => {
3070                            let _ = reply.send(self.max_connections);
3071                        }
3072                        Some(TorrentCommand::SetMaxUploads { limit, reply }) => {
3073                            self.choker.set_unchoke_slots(limit);
3074                            let _ = reply.send(());
3075                        }
3076                        Some(TorrentCommand::MaxUploads { reply }) => {
3077                            let _ = reply.send(self.choker.unchoke_slots());
3078                        }
3079                        Some(TorrentCommand::GetPeerInfo { reply }) => {
3080                            let _ = reply.send(self.build_peer_info());
3081                        }
3082                        Some(TorrentCommand::GetDownloadQueue { reply }) => {
3083                            let _ = reply.send(self.build_download_queue());
3084                        }
3085                        Some(TorrentCommand::HavePiece { index, reply }) => {
3086                            let has = self.chunk_tracker.as_ref()
3087                                .is_some_and(|ct| ct.has_piece(index));
3088                            let _ = reply.send(has);
3089                        }
3090                        Some(TorrentCommand::PieceAvailability { reply }) => {
3091                            // M246 (D5): this O(num_peers * num_pieces) scan is the
3092                            // only super-linear residual handler, but it is a RARE
3093                            // on-demand read query — its sole caller is the public
3094                            // `piece_availability()` accessor, driven by the client's
3095                            // GUI/WebUI piece-availability bar, never per-tick or
3096                            // per-message. Incremental maintenance (hooking every
3097                            // connect / disconnect / HAVE to keep a running counter)
3098                            // is deliberately out of the L4 recv-loop-hardening scope:
3099                            // it would add steady-state cost to the hot path to speed
3100                            // up a query a human triggers a few times a minute.
3101                            let mut avail = vec![0u32; self.num_pieces as usize];
3102                            for peer in self.peers.values() {
3103                                for i in 0..self.num_pieces {
3104                                    if peer.bitfield.get(i) {
3105                                        avail[i as usize] += 1;
3106                                    }
3107                                }
3108                            }
3109                            let _ = reply.send(avail);
3110                        }
3111                        Some(TorrentCommand::FileProgress { reply }) => {
3112                            let _ = reply.send(self.compute_file_progress());
3113                        }
3114                        Some(TorrentCommand::InfoHashes { reply }) => {
3115                            let _ = reply.send(self.info_hashes.clone());
3116                        }
3117                        Some(TorrentCommand::TorrentFile { reply }) => {
3118                            let _ = reply.send(self.meta.clone());
3119                        }
3120                        Some(TorrentCommand::TorrentFileV2 { reply }) => {
3121                            let _ = reply.send(self.meta_v2.clone());
3122                        }
3123                        Some(TorrentCommand::ForceDhtAnnounce) => {
3124                            self.handle_force_dht_announce().await;
3125                        }
3126                        Some(TorrentCommand::ReadPiece { index, reply }) => {
3127                            let result = self.handle_read_piece(index).await;
3128                            let _ = reply.send(result);
3129                        }
3130                        Some(TorrentCommand::FlushCache { reply }) => {
3131                            let result = self.handle_flush_cache().await;
3132                            let _ = reply.send(result);
3133                        }
3134                        Some(TorrentCommand::ClearError) => {
3135                            self.handle_clear_error().await;
3136                        }
3137                        Some(TorrentCommand::ClearSaveResumeFlag) => {
3138                            self.need_save_resume = false;
3139                        }
3140                        Some(TorrentCommand::MarkResumeDirty) => {
3141                            // M245 F1 — re-arm after a failed resume WRITE so the
3142                            // torrent is retried next save cycle (see
3143                            // `TakeResumeIfDirty`).
3144                            self.need_save_resume = true;
3145                        }
3146                        Some(TorrentCommand::RestoreResumeBitmap { pieces, reply }) => {
3147                            let result = self.handle_restore_resume_bitmap(pieces);
3148                            let _ = reply.send(result);
3149                        }
3150                        Some(TorrentCommand::RestoreWebSeedStats { stats, reply }) => {
3151                            self.web_seed_stats = stats;
3152                            let _ = reply.send(Ok(()));
3153                        }
3154                        Some(TorrentCommand::GetPeerSourceCounts { reply }) => {
3155                            let _ = reply.send((self.pex_peer_count, self.lsd_peer_count));
3156                        }
3157                        Some(TorrentCommand::QueryUnchokeDurations { reply }) => {
3158                            let mut out = self.unchoke_durations.clone();
3159                            // Merge in each currently-live peer's transient
3160                            // accumulator + any in-flight unchoke window.
3161                            let now = Instant::now();
3162                            for peer in self.peers.values() {
3163                                let mut delta = peer.unchoke_duration_total;
3164                                if let Some(start) = peer.am_unchoke_started_at {
3165                                    delta += now.duration_since(start);
3166                                }
3167                                if !delta.is_zero() {
3168                                    *out.entry(peer.addr).or_default() += delta;
3169                                }
3170                            }
3171                            let _ = reply.send(out);
3172                        }
3173                        Some(TorrentCommand::GetWebSeedStats { reply }) => {
3174                            let snapshot: Vec<_> = self.web_seed_stats.values().cloned().collect();
3175                            let _ = reply.send(snapshot);
3176                        }
3177                        Some(TorrentCommand::FileStatus { reply }) => {
3178                            let _ = reply.send(self.build_file_status());
3179                        }
3180                        Some(TorrentCommand::Flags { reply }) => {
3181                            let _ = reply.send(self.build_flags());
3182                        }
3183                        Some(TorrentCommand::SetFlags { flags, reply }) => {
3184                            self.apply_set_flags(flags).await;
3185                            let _ = reply.send(());
3186                        }
3187                        Some(TorrentCommand::UnsetFlags { flags, reply }) => {
3188                            self.apply_unset_flags(flags).await;
3189                            let _ = reply.send(());
3190                        }
3191                        Some(TorrentCommand::ConnectPeer { addr }) => {
3192                            self.handle_connect_peer(addr);
3193                        }
3194                        Some(TorrentCommand::PreResolvedMetadata { info_bytes, peers }) => {
3195                            self.handle_pre_resolved_metadata(info_bytes, peers).await;
3196                        }
3197                        #[cfg(feature = "test-util")]
3198                        Some(TorrentCommand::TestInjectMetadata { info_bytes, reply }) => {
3199                            // Reuses the existing handle_pre_resolved_metadata at torrent.rs:3665.
3200                            // Synchronous because the test caller depends on completion before
3201                            // proceeding (unlike the production resolver which is fire-and-forget).
3202                            self.handle_pre_resolved_metadata(info_bytes, vec![]).await;
3203                            let _ = reply.send(());
3204                        }
3205                        Some(TorrentCommand::GetMeta { reply }) => {
3206                            // v0.173.1: single source of truth for torrent
3207                            // metadata — replaces `SessionActor.TorrentEntry.meta`
3208                            // so magnet-added torrents no longer lose the info
3209                            // dict between the TorrentActor and the session.
3210                            let _ = reply.send(self.meta.clone());
3211                        }
3212                        Some(TorrentCommand::UpdateSettings(delta)) => {
3213                            self.handle_update_settings(&delta);
3214                        }
3215                        Some(TorrentCommand::Shutdown) => {
3216                            info!("torrent actor: received Shutdown command, exiting");
3217                            self.shutdown_web_seeds().await;
3218                            self.shutdown_peers().await;
3219                            return;
3220                        }
3221                        None => {
3222                            warn!("torrent actor: cmd_rx channel closed (all senders dropped), exiting");
3223                            self.shutdown_web_seeds().await;
3224                            self.shutdown_peers().await;
3225                            return;
3226                        }
3227                    }
3228                }
3229                // Async disk write errors
3230                Some(err) = self.write_error_rx.recv() => {
3231                    warn!(piece = err.piece, begin = err.begin, "async disk write failed: {}", err.error);
3232                }
3233                // Accept incoming peers
3234                result = accept_incoming(&mut self.listener) => {
3235                    if let Ok((stream, addr)) = result {
3236                        self.spawn_peer_from_stream(addr, stream);
3237                    }
3238                }
3239                // Accept incoming I2P peers (M41)
3240                stream = accept_i2p(&mut self.i2p_accept_rx) => {
3241                    if let Some(stream) = stream {
3242                        self.handle_i2p_incoming(stream);
3243                    }
3244                }
3245                // Rate update timer (2s) — decoupled from the 10s unchoke
3246                // interval so the GUI sees responsive DL/UL numbers.
3247                _ = rate_interval.tick() => {
3248                    self.update_peer_rates();
3249                }
3250                // Unchoke timer
3251                _ = unchoke_interval.tick() => {
3252                    // M144 deviation from BEP 3 §3 (10s choking algorithm):
3253                    // We skip the choker during download — intentional rqbit-parity
3254                    // decision. BEP 3 specifies a 10s choking interval to manage
3255                    // upload slots via tit-for-tat. But IronTide unchokes all peers
3256                    // unconditionally on connect (M107); running the choker every
3257                    // 10s re-chokes most peers, breaking the reciprocity that drove
3258                    // the original unchoke. Only run during seeding/sharing where
3259                    // upload-slot management matters. Reviewed M174.
3260                    if self.state == TorrentState::Seeding
3261                        || self.state == TorrentState::Sharing
3262                    {
3263                        self.slot_tuner.observe(self.upload_bytes_interval);
3264                        self.choker.observe_throughput(self.upload_bytes_interval);
3265                        self.upload_bytes_interval = 0;
3266                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3267                        self.run_choker().await;
3268                        // v0.187.3: a normal tick satisfies the immediate-tick
3269                        // request, so clear the flag.
3270                        self.force_immediate_choker_tick = false;
3271                    } else {
3272                        self.upload_bytes_interval = 0;
3273                    }
3274                    // Update streaming cursors and piece priorities
3275                    self.update_streaming_cursors();
3276                    // Update auto-sequential hysteresis (M45)
3277                    if self.config.auto_sequential {
3278                        self.auto_sequential_active = crate::piece_selector::evaluate_auto_sequential(
3279                            self.piece_owner.iter().filter(|o| o.is_some()).count(),
3280                            self.peers.len(),
3281                            self.auto_sequential_active,
3282                        );
3283                    }
3284                    // Periodic web seed piece reassignment (moved from dht_recheck timer)
3285                    self.assign_pieces_to_web_seeds();
3286                }
3287                // Optimistic unchoke timer
3288                _ = optimistic_interval.tick() => {
3289                    self.rotate_optimistic();
3290                }
3291                // M107: Receive connect requests from the peer adder task
3292                Some(connect_peer) = async {
3293                    match self.connect_rx.as_mut() {
3294                        Some(rx) => rx.recv().await,
3295                        None => std::future::pending().await,
3296                    }
3297                } => {
3298                    self.handle_adder_connect(connect_peer);
3299                }
3300                () = &mut dht_requery_sleep, if self.state != TorrentState::Complete
3301                    && self.state != TorrentState::Paused
3302                    && self.state != TorrentState::Queued
3303                    && self.state != TorrentState::Seeding
3304                    && self.state != TorrentState::Stopped => {
3305                    self.run_dht_requery().await;
3306                    dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::from_mins(1)));
3307                }
3308                // M143: Tracker re-announce timer — starts a background announce,
3309                // never blocks. Only fires when no announce is in-flight.
3310                // Also fires during FetchingMetadata so magnets with &tr= URLs
3311                // can discover peers before metadata arrives.
3312                () = async {
3313                    match self.tracker_manager.next_announce_in() {
3314                        Some(dur) => tokio::time::sleep(dur).await,
3315                        None => std::future::pending().await,
3316                    }
3317                }, if self.tracker_result_rx.is_none() => {
3318                    let left = self.calculate_left();
3319                    self.tracker_result_rx = Some(self.tracker_manager.start_announce(
3320                        irontide_tracker::AnnounceEvent::None,
3321                        self.uploaded,
3322                        self.downloaded,
3323                        left,
3324                    ));
3325                }
3326                // M143: Streaming tracker results — process each tracker
3327                // response as it arrives, without blocking the actor loop.
3328                result = async {
3329                    match self.tracker_result_rx.as_mut() {
3330                        Some(rx) => rx.recv().await,
3331                        None => std::future::pending().await,
3332                    }
3333                } => {
3334                    match result {
3335                        Some(batch) => {
3336                            let (peers, outcome) = self.tracker_manager.process_tracker_result(batch);
3337                            self.fire_tracker_alerts(&[outcome]);
3338                            if !peers.is_empty() {
3339                                debug!(count = peers.len(), "tracker returned peers (streaming)");
3340                                self.handle_add_peers(peers, PeerSource::Tracker);
3341                            }
3342                        }
3343                        None => {
3344                            // All trackers responded — clear in-flight state so
3345                            // the timer arm can re-fire for the next announce cycle.
3346                            self.tracker_result_rx = None;
3347                        }
3348                    }
3349                }
3350                // DHT v4 peer discovery
3351                result = async {
3352                    match &mut self.dht_peers_rx {
3353                        Some(rx) => rx.recv().await,
3354                        None => std::future::pending().await,
3355                    }
3356                } => {
3357                    if let Some(peers) = result {
3358                        debug!(count = peers.len(), "DHT v4 returned peers");
3359                        self.handle_add_peers(peers, PeerSource::Dht);
3360                    } else {
3361                        debug!("DHT v4 peer search exhausted");
3362                        self.dht_peers_rx = None;
3363                    }
3364                }
3365                // DHT v6 peer discovery
3366                result = async {
3367                    match &mut self.dht_v6_peers_rx {
3368                        Some(rx) => rx.recv().await,
3369                        None => std::future::pending().await,
3370                    }
3371                } => {
3372                    if let Some(peers) = result {
3373                        debug!(count = peers.len(), "DHT v6 returned peers");
3374                        self.dht_v6_empty_count = 0; // V6 is working, reset
3375                        self.handle_add_peers(peers, PeerSource::Dht);
3376                    } else {
3377                        self.dht_v6_peers_rx = None;
3378                        self.dht_v6_empty_count += 1;
3379                        if self.dht_v6_empty_count == 30 {
3380                            debug!("DHT v6 routing table persistently empty, giving up");
3381                        } else if self.dht_v6_empty_count < 30 {
3382                            debug!("DHT v6 peer search exhausted");
3383                        }
3384                    }
3385                }
3386                // Dual-swarm: DHT v4 v2-hash peer discovery (hybrid)
3387                result = async {
3388                    match &mut self.dht_v2_peers_rx {
3389                        Some(rx) => rx.recv().await,
3390                        None => std::future::pending().await,
3391                    }
3392                } => {
3393                    if let Some(peers) = result {
3394                        debug!(count = peers.len(), "DHT v4 v2-swarm returned peers");
3395                        self.handle_add_peers(peers, PeerSource::Dht);
3396                    } else {
3397                        debug!("DHT v4 v2-swarm peer search exhausted");
3398                        self.dht_v2_peers_rx = None;
3399                    }
3400                }
3401                // Dual-swarm: DHT v6 v2-hash peer discovery (hybrid)
3402                result = async {
3403                    match &mut self.dht_v6_v2_peers_rx {
3404                        Some(rx) => rx.recv().await,
3405                        None => std::future::pending().await,
3406                    }
3407                } => {
3408                    if let Some(peers) = result {
3409                        debug!(count = peers.len(), "DHT v6 v2-swarm returned peers");
3410                        self.handle_add_peers(peers, PeerSource::Dht);
3411                    } else {
3412                        debug!("DHT v6 v2-swarm peer search exhausted");
3413                        self.dht_v6_v2_peers_rx = None;
3414                    }
3415                }
3416                // M44: Suggest cached pieces timer
3417                _ = async {
3418                    match suggest_interval {
3419                        Some(ref mut interval) => interval.tick().await,
3420                        None => std::future::pending().await,
3421                    }
3422                } => {
3423                    self.suggest_cached_pieces().await;
3424                }
3425                _ = turnover_interval.tick() => {
3426                    self.run_steal_queue_maintenance();
3427                }
3428                // Pipeline tick (1s) — update EWMA, snub detection, peer scoring
3429                _ = pipeline_tick_interval.tick() => {
3430                    let snub_timeout = Duration::from_secs(u64::from(self.config.snub_timeout_secs));
3431
3432                    for (_addr, peer) in &mut self.peers {
3433                        peer.pipeline.tick();
3434
3435                        // Snub detection: no data for snub_timeout_secs while unchoked
3436                        if !peer.peer_choking && !peer.snubbed {
3437                            let idle = peer.last_data_received
3438                                .is_some_and(|t| t.elapsed() > snub_timeout);
3439                            if idle {
3440                                peer.snubbed = true;
3441                                // M106: Count pending requests as timed-out blocks
3442                                peer.blocks_timed_out = peer.blocks_timed_out
3443                                    .saturating_add(peer.pending_requests.len() as u64);
3444                                debug!(%_addr, "peer snubbed (no data for {}s)", self.config.snub_timeout_secs);
3445                            }
3446                        }
3447                    }
3448
3449                    // Refresh cached peer rates for steal decisions (avoids
3450                    // rebuilding a FxHashMap from all peers on every block arrival).
3451                    self.refresh_peer_rates();
3452
3453                    // M73: Periodic endgame activation check (was in batch_fill_all_peers)
3454                    if !self.end_game.is_active() {
3455                        self.check_end_game_activation();
3456                    }
3457
3458                    self.tick_dispatch_safety_wake();
3459
3460                    // M138: Proactive choke rotation — every tick, evict up to N choked peers
3461                    if self.config.choke_rotation_max_evictions > 0
3462                        && self.state == TorrentState::Downloading
3463                    {
3464                        self.run_choke_rotation();
3465                    }
3466
3467                    // M246: coalesced order-map rebuild. The SetFilePriority arm sets
3468                    // `order_map_dirty` instead of building inline; a batch of priority
3469                    // changes collapses to ONE rebuild here (at most ~1 s later — the
3470                    // order map is an advisory dispatch walk-order hint, and the
3471                    // piece_tracker already gates wanted/reserved synchronously).
3472                    if self.order_map_dirty {
3473                        self.rebuild_order_map_now();
3474                    }
3475                }
3476                // (M75: peer tasks handle dispatch via integrated select! arm)
3477                // End-game refill tick (200ms) — replace reactive per-block cascade
3478                // with periodic batch refill. All peers with available pipeline slots
3479                // get new end-game blocks, preventing idle stalls between ticks.
3480                _ = end_game_tick_interval.tick(), if self.end_game.is_active() => {
3481                    let addrs: Vec<SocketAddr> = self.peers.iter()
3482                        .filter(|(_, p)| !p.peer_choking && p.pending_requests.len() < END_GAME_DEPTH)
3483                        .map(|(addr, _)| *addr)
3484                        .collect();
3485                    for addr in addrs {
3486                        self.request_end_game_block(addr).await;
3487                    }
3488                }
3489                // M107: Metadata piece timeout — re-request timed-out pieces from
3490                // all non-rejected peers that support ut_metadata.
3491                _ = metadata_timeout_interval.tick(), if self.state == TorrentState::FetchingMetadata => {
3492                    // Collect timed-out pieces (immutable borrow, then release).
3493                    let timed_out: Vec<u32> = self
3494                        .metadata_downloader
3495                        .as_ref()
3496                        .map(MetadataDownloader::timed_out_pieces)
3497                        .unwrap_or_default();
3498
3499                    if !timed_out.is_empty() {
3500                        debug!(count = timed_out.len(), "metadata pieces timed out, re-requesting");
3501
3502                        // Collect eligible peers (non-rejected, support ut_metadata).
3503                        // Clone cmd_tx to avoid holding borrows across the send loop.
3504                        let eligible_senders: Vec<mpsc::Sender<PeerCommand>> = self
3505                            .peers
3506                            .iter()
3507                            .filter(|(addr, peer)| {
3508                                self.metadata_downloader
3509                                    .as_ref()
3510                                    .is_some_and(|dl| !dl.is_rejected(addr))
3511                                    && peer
3512                                        .ext_handshake
3513                                        .as_ref()
3514                                        .is_some_and(|h| h.metadata_size.is_some())
3515                            })
3516                            .map(|(_, peer)| peer.cmd_tx.clone())
3517                            .collect();
3518
3519                        // Send requests (uses cloned senders, no borrow conflict).
3520                        for cmd_tx in &eligible_senders {
3521                            for &piece in &timed_out {
3522                                let _ = cmd_tx.try_send(PeerCommand::RequestMetadata { piece });
3523                            }
3524                        }
3525
3526                        // Update request times in the downloader.
3527                        if let Some(ref mut dl) = self.metadata_downloader {
3528                            for piece in timed_out {
3529                                dl.reset_request_time(piece);
3530                            }
3531                        }
3532                    }
3533                }
3534                // Periodic download status report (5s)
3535                _ = diag_interval.tick() => {
3536                    // Heartbeat: log state regardless of download state
3537                    {
3538                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3539                        let eg = self.end_game.is_active();
3540                        let eg_blocks = self.end_game.block_count();
3541                        info!(state = ?self.state, have, total = self.num_pieces, end_game = eg, eg_blocks, "heartbeat");
3542                    }
3543                    if self.state == TorrentState::Downloading {
3544                        let have = self.chunk_tracker.as_ref().map_or(0, |ct| ct.bitfield().count_ones());
3545                        let in_flight = self.atomic_states.as_ref().map_or(0, |s| s.in_flight_count() as usize);
3546                        let unchoked = self.peers.values().filter(|p| !p.peer_choking).count();
3547                        info!(have, in_flight, total = self.num_pieces,
3548                              downloaded_mb = self.downloaded / (1024 * 1024),
3549                              peers = self.peers.len(), unchoked,
3550                              "download progress");
3551                        for (addr, p) in &self.peers {
3552                            let last_data = p.last_data_received.map_or(9999, |t| t.elapsed().as_secs());
3553                            trace!(%addr,
3554                                   choking = p.peer_choking,
3555                                   pending = p.pending_requests.len(),
3556                                   ewma_rate = p.pipeline.ewma_rate() as u64,
3557                                   last_data_secs = last_data,
3558                                   bf_ones = p.bitfield.count_ones(),
3559                                   "peer state");
3560                        }
3561                    }
3562                }
3563                // M108: 30s connection success rate summary for variance diagnosis
3564                _ = conn_stats_interval.tick() => {
3565                    if self.connect_attempts > 0 {
3566                        let succeeded = self.connect_attempts.saturating_sub(self.connect_failures);
3567                        let success_pct = (succeeded as f64 / self.connect_attempts as f64 * 100.0) as u32;
3568                        info!(
3569                            connected = self.peers.len(),
3570                            attempted = self.connect_attempts,
3571                            failed = self.connect_failures,
3572                            success_rate = %format!("{success_pct}%"),
3573                            "connection stats"
3574                        );
3575                    }
3576                }
3577                // M147: Soft reap — disconnect connecting peers without TCP SYN-ACK.
3578                // v0.173.3: uses the buffer-fill variant + index iteration to reuse
3579                // soft_reap_buf across ticks without ever moving its heap allocation.
3580                // SocketAddr is Copy, so indexing into self.soft_reap_buf yields a
3581                // value copy and does not borrow self.soft_reap_buf for the loop body.
3582                _ = soft_reap_interval.tick() => {
3583                    let soft_timeout = self.config.connect_soft_timeout;
3584                    if soft_timeout > 0 {
3585                        if let Some(ref ps) = self.peer_states {
3586                            ps.soft_reap_candidates_into(
3587                                Duration::from_secs(soft_timeout),
3588                                &mut self.soft_reap_buf,
3589                            );
3590                        } else {
3591                            self.soft_reap_buf.clear();
3592                        }
3593                        for i in 0..self.soft_reap_buf.len() {
3594                            let peer_addr = self.soft_reap_buf[i];
3595                            debug!(%peer_addr, soft_timeout, "soft reap: no TCP SYN-ACK");
3596                            // Remove from connect_permits so RAII drops the permit
3597                            self.connect_permits.remove(&peer_addr);
3598                            self.disconnect_peer(peer_addr, "soft reap: no TCP SYN-ACK");
3599                            if let Some(ref ps) = self.peer_states
3600                                && let Some(backoff) = ps.mark_dead(peer_addr)
3601                            {
3602                                let ps_clone = Arc::clone(ps);
3603                                tokio::spawn(async move {
3604                                    tokio::time::sleep(backoff).await;
3605                                    ps_clone.mark_queued_for_retry(peer_addr);
3606                                });
3607                            }
3608                        }
3609                        self.soft_reap_buf.clear();
3610                    }
3611                }
3612                // M148 + v0.187.3: Proactive eviction with churn guard.
3613                //
3614                //                  PROACTIVE EVICTION POLICY (M148 → v0.187.3)
3615                //                  ============================================
3616                //
3617                //   Tick every 2s
3618                //   ├─ state == Seeding?                          ──── no-op
3619                //   ├─ live < (effective_max * 0.95)?             ──── no-op
3620                //   └─ eviction_history (in last 60s) < limit?    ──── no-op
3621                //       │
3622                //       ▼
3623                //   for up to 5 candidates:
3624                //     find_eviction_candidate() →
3625                //       Pass 0  ZeroThroughput     [skipped if state==Seeding]
3626                //               [skipped if peer.live_since < pass0_grace_secs]
3627                //               ban 10min, push to banned_set (FIFO cap 1024)
3628                //       Pass 1  Choked > 10s       no ban
3629                //       Pass 2  LowThroughput      no ban
3630                //       Pass 3  Bep40 priority     [only on HandshakeComplete]
3631                //               no ban
3632                //
3633                //     on evict: eviction_history.push_back(now)
3634                _ = eviction_interval.tick() => {
3635                    // v0.187.3 / Bug 8a: opportunistically service a pending
3636                    // immediate-tick request from a recent state transition
3637                    // (typically Downloading → Seeding). Caps the worst-case
3638                    // first-unchoke latency at the 2s eviction interval
3639                    // instead of the 10s unchoke interval.
3640                    if self.force_immediate_choker_tick
3641                        && (self.state == TorrentState::Seeding
3642                            || self.state == TorrentState::Sharing)
3643                    {
3644                        self.slot_tuner.observe(self.upload_bytes_interval);
3645                        self.choker.observe_throughput(self.upload_bytes_interval);
3646                        self.upload_bytes_interval = 0;
3647                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
3648                        self.run_choker().await;
3649                        self.force_immediate_choker_tick = false;
3650                    }
3651                    if self.state != TorrentState::Seeding {
3652                        // v0.187.3 / 3A: prune eviction_history of entries older
3653                        // than 60s, then gate on the configured limit.
3654                        let prune_cutoff = std::time::Duration::from_mins(1);
3655                        while self
3656                            .eviction_history
3657                            .front()
3658                            .copied()
3659                            .is_some_and(|t| t.elapsed() > prune_cutoff)
3660                        {
3661                            self.eviction_history.pop_front();
3662                        }
3663                        let limit = self.config.proactive_evictions_per_minute_limit as usize;
3664                        let window_ok = self.eviction_history.len() < limit;
3665
3666                        // v0.187.3 / Pressure gate: 0.95 (was 0.75 in v0.187.2).
3667                        // Higher threshold gives slow-start peers room to ramp
3668                        // before the eviction loop fires.
3669                        let should_evict = window_ok
3670                            && self.peer_states.as_ref().is_some_and(|ps| {
3671                                let live = ps
3672                                    .stats
3673                                    .live
3674                                    .load(std::sync::atomic::Ordering::Relaxed);
3675                                #[allow(
3676                                    clippy::cast_possible_truncation,
3677                                    clippy::cast_sign_loss
3678                                )]
3679                                let threshold =
3680                                    (self.effective_max_connections() as f32 * 0.95) as u32;
3681                                debug_assert!(
3682                                    self.effective_max_connections()
3683                                        <= crate::torrent_peers::HARD_PEER_CEILING,
3684                                    "effective_max must be clamped to HARD_PEER_CEILING"
3685                                );
3686                                live >= threshold
3687                            });
3688                        if should_evict {
3689                            // Evict up to 5 deadweight per tick, but no more than
3690                            // (limit - history.len()) total per the sliding window.
3691                            let max_this_tick = 5.min(limit.saturating_sub(self.eviction_history.len()));
3692                            for _ in 0..max_this_tick {
3693                                match self.find_eviction_candidate() {
3694                                    Some((victim, pass)) => {
3695                                        debug!(%victim, ?pass, "v0.187.3 proactive eviction");
3696                                        self.disconnect_peer(victim, "proactive eviction");
3697                                        if matches!(pass, crate::torrent_peers::EvictionPass::ZeroThroughput)
3698                                            && let Some(ref ps) = self.peer_states
3699                                        {
3700                                            ps.add_eviction_ban(victim);
3701                                        }
3702                                        self.eviction_history.push_back(std::time::Instant::now());
3703                                    }
3704                                    None => break,
3705                                }
3706                            }
3707                        }
3708
3709                        // M149: Piece stealing scan (piggybacks on same 2s interval)
3710                        self.run_piece_steal_scan();
3711                    }
3712                }
3713                // Rate limiter refill (100ms)
3714                _ = refill_interval.tick() => {
3715                    let elapsed = Duration::from_millis(100);
3716                    self.upload_bucket.refill(elapsed);
3717                    self.download_bucket.lock().refill(elapsed);
3718                    // Refill per-class buckets and apply mixed-mode (M45)
3719                    self.rate_limiter_set.refill(elapsed);
3720                    let (tcp_peers, utp_peers) = self.transport_peer_counts();
3721                    self.rate_limiter_set.apply_mixed_mode(
3722                        self.config.mixed_mode_algorithm,
3723                        tcp_peers,
3724                        utp_peers,
3725                        self.config.upload_rate_limit,
3726                    );
3727                }
3728            }
3729
3730            // M112: drain holepunch attempts (bridging sync disconnect_peer → async try_holepunch)
3731            for target in std::mem::take(&mut self.holepunch_pending) {
3732                self.try_holepunch(target).await;
3733            }
3734        }
3735    }
3736
3737    // ----- Command handlers -----
3738
3739    /// Compute distributed copy availability across the swarm.
3740    ///
3741    /// Returns `(full_copies, fraction, copies_float)` where `fraction` is in thousandths.
3742    pub(crate) fn distributed_copies(&self) -> (u32, u32, f32) {
3743        if self.num_pieces == 0 || self.peers.is_empty() {
3744            return (0, 0, 0.0);
3745        }
3746
3747        let num = self.num_pieces as usize;
3748        let mut availability = vec![0u32; num];
3749
3750        for peer in self.peers.values() {
3751            for idx in 0..self.num_pieces {
3752                if peer.bitfield.get(idx) {
3753                    availability[idx as usize] += 1;
3754                }
3755            }
3756        }
3757
3758        let min_avail = availability.iter().copied().min().unwrap_or(0);
3759        let rarest_count = availability.iter().filter(|&&c| c == min_avail).count() as u32;
3760        let fraction = ((self.num_pieces - rarest_count) * 1000) / self.num_pieces;
3761        let copies_float = min_avail as f32 + fraction as f32 / 1000.0;
3762
3763        (min_avail, fraction, copies_float)
3764    }
3765
3766    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3767    /// `GetDownloadQueue` query) — intentionally left on the recv loop; it has
3768    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3769    fn build_download_queue(&self) -> Vec<PartialPieceInfo> {
3770        self.piece_owner
3771            .iter()
3772            .enumerate()
3773            .filter_map(|(piece_index, owner)| {
3774                owner.map(|_| {
3775                    let piece_index = piece_index as u32;
3776                    let blocks_in_piece = self
3777                        .lengths
3778                        .as_ref()
3779                        .map_or(0, |l| l.piece_size(piece_index).div_ceil(l.chunk_size()));
3780                    PartialPieceInfo {
3781                        piece_index,
3782                        blocks_in_piece,
3783                        blocks_assigned: 0,
3784                    }
3785                })
3786            })
3787            .collect()
3788    }
3789
3790    /// Compute per-file downloaded bytes.
3791    ///
3792    /// M246 (D5): rare on-demand `O(num_pieces)` read-model builder (the
3793    /// `FileProgress` query) — intentionally left on the recv loop; it has
3794    /// no per-tick / per-message caller, so off-loading would not help the hot path.
3795    fn compute_file_progress(&self) -> Vec<u64> {
3796        let Some(meta) = self.meta.as_ref() else {
3797            return Vec::new();
3798        };
3799        let Some(lengths) = self.lengths.as_ref() else {
3800            return Vec::new();
3801        };
3802        let Some(chunk_tracker) = self.chunk_tracker.as_ref() else {
3803            return Vec::new();
3804        };
3805
3806        let files = meta.info.files();
3807        if files.is_empty() {
3808            return Vec::new();
3809        }
3810
3811        let piece_length = lengths.piece_length();
3812        let mut result = Vec::with_capacity(files.len());
3813        let mut file_offset = 0u64;
3814
3815        for file_entry in &files {
3816            let file_len = file_entry.length;
3817            if file_len == 0 {
3818                result.push(0);
3819                file_offset += file_len;
3820                continue;
3821            }
3822
3823            let file_end = file_offset + file_len;
3824            let first_piece = (file_offset / piece_length) as u32;
3825            let last_piece = ((file_end - 1) / piece_length) as u32;
3826
3827            let mut downloaded = 0u64;
3828
3829            for p in first_piece..=last_piece {
3830                if !chunk_tracker.has_piece(p) {
3831                    continue;
3832                }
3833
3834                let piece_start = lengths.piece_offset(p);
3835                let piece_end = piece_start + u64::from(lengths.piece_size(p));
3836
3837                // Clamp to file boundaries
3838                let overlap_start = piece_start.max(file_offset);
3839                let overlap_end = piece_end.min(file_end);
3840
3841                if overlap_start < overlap_end {
3842                    downloaded += overlap_end - overlap_start;
3843                }
3844            }
3845
3846            result.push(downloaded);
3847            file_offset = file_end;
3848        }
3849
3850        result
3851    }
3852
3853    /// Exponential backoff delay for V6 DHT retries (M97).
3854    /// 100ms, 200ms, 400ms, 800ms, 1600ms, 3200ms, 5000ms (cap).
3855    fn v6_retry_delay(&self) -> std::time::Duration {
3856        let base_ms: u64 = 100;
3857        let max_ms: u64 = 5000;
3858        let delay_ms = base_ms
3859            .saturating_mul(
3860                1u64.checked_shl(self.dht_v6_empty_count)
3861                    .unwrap_or(u64::MAX),
3862            )
3863            .min(max_ms);
3864        std::time::Duration::from_millis(delay_ms)
3865    }
3866
3867    /// Check if enough time has elapsed for the next V6 DHT retry (M97).
3868    fn should_retry_v6(&self) -> bool {
3869        let Some(last) = self.dht_v6_last_retry else {
3870            return true; // First attempt
3871        };
3872        last.elapsed() >= self.v6_retry_delay()
3873    }
3874
3875    /// Force an immediate DHT announce on all available DHT handles (v4 + v6).
3876    async fn handle_force_dht_announce(&self) {
3877        if let Some(dht) = self.current_dht()
3878            && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
3879        {
3880            warn!("Force DHT v4 announce failed: {e}");
3881        }
3882        if let Some(dht6) = self.current_dht_v6()
3883            && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
3884        {
3885            debug!("Force DHT v6 announce failed: {e}");
3886        }
3887        // Dual-swarm: also announce v2 hash for hybrid torrents
3888        if self.info_hashes.is_hybrid()
3889            && let Some(v2) = self.info_hashes.v2
3890        {
3891            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
3892            if v2_as_v1 != self.info_hash {
3893                if let Some(dht) = self.current_dht()
3894                    && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
3895                {
3896                    debug!("Force DHT v4 dual-swarm announce failed: {e}");
3897                }
3898                if let Some(dht6) = self.current_dht_v6()
3899                    && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
3900                {
3901                    debug!("Force DHT v6 dual-swarm announce failed: {e}");
3902                }
3903            }
3904        }
3905    }
3906
3907    /// M107: Periodic DHT re-query — discovers new peers during download.
3908    ///
3909    /// Replaces the old fixed 30s `dht_recheck_interval`. Clears the adder's
3910    /// seen set so previously-known peers can be re-evaluated, then issues
3911    /// fresh `get_peers` on all active DHT handles (v4, v6, v2-swarm).
3912    async fn run_dht_requery(&mut self) {
3913        if !self.config.enable_dht {
3914            return;
3915        }
3916
3917        // Guard: don't re-query if we already have plenty of known peers.
3918        // M133: Scale with config instead of hardcoded 500 — with max_peers=128
3919        // this becomes 512, close to the old value but adapts to custom limits.
3920        if self.peers.len() > self.config.max_peers.saturating_mul(4) {
3921            return;
3922        }
3923
3924        // M134: DhtLookup is now persistent — it re-injects routing table roots
3925        // every 15s internally. Only issue a fresh get_peers if the previous
3926        // lookup's channel has closed (lookup exhausted or aborted). Issuing a
3927        // new get_peers while one is active would abort the existing DhtLookup,
3928        // destroying its accumulated 256-node state.
3929
3930        // v4 DHT — only start if no active lookup
3931        if self.dht_peers_rx.is_none()
3932            && let Some(dht) = self.current_dht()
3933        {
3934            match dht.get_peers(self.info_hash).await {
3935                Ok(rx) => self.dht_peers_rx = Some(rx),
3936                Err(e) => warn!("DHT v4 re-query failed: {e}"),
3937            }
3938        }
3939
3940        // v6 DHT — only start if no active lookup
3941        if self.dht_v6_peers_rx.is_none()
3942            && self.dht_v6_empty_count < 30
3943            && self.should_retry_v6()
3944            && let Some(dht6) = self.current_dht_v6()
3945        {
3946            self.dht_v6_last_retry = Some(std::time::Instant::now());
3947            match dht6.get_peers(self.info_hash).await {
3948                Ok(rx) => self.dht_v6_peers_rx = Some(rx),
3949                Err(e) => debug!("DHT v6 re-query failed: {e}"),
3950            }
3951        }
3952
3953        // v2 swarm re-query for hybrid torrents — only start if no active lookup
3954        if self.info_hashes.is_hybrid()
3955            && let Some(v2) = self.info_hashes.v2
3956        {
3957            let v2_bytes: [u8; 20] = v2.0[..20]
3958                .try_into()
3959                .expect("Id32 is 32 bytes; first 20 always fit");
3960            let v2_as_v1 = Id20(v2_bytes);
3961
3962            if self.dht_v2_peers_rx.is_none()
3963                && let Some(dht) = self.current_dht()
3964            {
3965                match dht.get_peers(v2_as_v1).await {
3966                    Ok(rx) => self.dht_v2_peers_rx = Some(rx),
3967                    Err(e) => debug!("DHT v4 v2-swarm re-query failed: {e}"),
3968                }
3969            }
3970            if self.dht_v6_v2_peers_rx.is_none()
3971                && self.dht_v6_empty_count < 30
3972                && self.should_retry_v6()
3973                && let Some(dht6) = self.current_dht_v6()
3974            {
3975                self.dht_v6_last_retry = Some(std::time::Instant::now());
3976                match dht6.get_peers(v2_as_v1).await {
3977                    Ok(rx) => self.dht_v6_v2_peers_rx = Some(rx),
3978                    Err(e) => debug!("DHT v6 v2-swarm re-query failed: {e}"),
3979                }
3980            }
3981        }
3982
3983        debug!(peers = self.peers.len(), "DHT re-query triggered");
3984    }
3985
3986    /// Read a complete piece from disk by reading all chunks and concatenating.
3987    async fn handle_read_piece(&self, index: u32) -> crate::Result<Bytes> {
3988        let disk = self
3989            .disk
3990            .as_ref()
3991            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3992        let lengths = self
3993            .lengths
3994            .as_ref()
3995            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3996
3997        let piece_size = lengths.piece_size(index);
3998        if piece_size == 0 {
3999            return Err(crate::Error::InvalidPieceIndex {
4000                index,
4001                num_pieces: lengths.num_pieces(),
4002            });
4003        }
4004
4005        let chunk_size = lengths.chunk_size();
4006        let num_chunks = lengths.chunks_in_piece(index);
4007        let mut buf = bytes::BytesMut::with_capacity(piece_size as usize);
4008
4009        for chunk_idx in 0..num_chunks {
4010            let begin = chunk_idx * chunk_size;
4011            let len = if chunk_idx == num_chunks - 1 {
4012                piece_size - begin
4013            } else {
4014                chunk_size
4015            };
4016            let data = disk
4017                .read_chunk(index, begin, len, DiskJobFlags::empty())
4018                .await
4019                .map_err(crate::Error::Storage)?;
4020            buf.extend_from_slice(&data);
4021        }
4022
4023        Ok(buf.freeze())
4024    }
4025
4026    /// Flush the disk write cache.
4027    async fn handle_flush_cache(&self) -> crate::Result<()> {
4028        let disk = self
4029            .disk
4030            .as_ref()
4031            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4032        disk.flush_cache().await.map_err(crate::Error::Storage)
4033    }
4034
4035    /// Immediately initiate a connection to the given peer address.
4036    fn handle_connect_peer(&mut self, addr: SocketAddr) {
4037        // Skip if already connected
4038        if self.peers.contains_key(&addr) {
4039            return;
4040        }
4041        // M137: Track via unified PeerStates lifecycle
4042        if let Some(ref ps) = self.peer_states {
4043            ps.add_if_not_seen(addr, PeerSource::Incoming);
4044        }
4045    }
4046
4047    /// Fire `TrackerReply` / `TrackerError` alerts from announce outcomes.
4048    pub(crate) fn fire_tracker_alerts(&self, outcomes: &[crate::tracker_manager::TrackerOutcome]) {
4049        for outcome in outcomes {
4050            match &outcome.result {
4051                Ok(num_peers) => {
4052                    post_alert(
4053                        &self.alert_tx,
4054                        &self.alert_mask,
4055                        AlertKind::TrackerReply {
4056                            info_hash: self.info_hash,
4057                            url: outcome.url.clone(),
4058                            num_peers: *num_peers,
4059                        },
4060                    );
4061                }
4062                Err(msg) => {
4063                    post_alert(
4064                        &self.alert_tx,
4065                        &self.alert_mask,
4066                        AlertKind::TrackerError {
4067                            info_hash: self.info_hash,
4068                            url: outcome.url.clone(),
4069                            message: msg.clone(),
4070                        },
4071                    );
4072                }
4073            }
4074        }
4075    }
4076
4077    /// Calculate bytes remaining for tracker announce.
4078    pub(crate) fn calculate_left(&self) -> u64 {
4079        match (&self.meta, &self.chunk_tracker) {
4080            (Some(meta), Some(ct)) => {
4081                let total = meta.info.total_length();
4082                let have = u64::from(ct.bitfield().count_ones());
4083                let pieces_total = u64::from(self.num_pieces);
4084                let per_piece = total.checked_div(pieces_total).unwrap_or(0);
4085                total.saturating_sub(have * per_piece)
4086            }
4087            _ => 0,
4088        }
4089    }
4090
4091    pub(crate) async fn shutdown_peers(&mut self) {
4092        // Best-effort announce Stopped to trackers (with timeout to prevent hang)
4093        let left = self.calculate_left();
4094        let _ = tokio::time::timeout(
4095            std::time::Duration::from_secs(3),
4096            self.tracker_manager
4097                .announce_stopped(self.uploaded, self.downloaded, left),
4098        )
4099        .await;
4100
4101        // Non-blocking peer shutdown — peers may already be dead or channels full
4102        for peer in self.peers.values() {
4103            let _ = peer.cmd_tx.try_send(PeerCommand::Shutdown);
4104        }
4105    }
4106
4107    // ----- Event handlers -----
4108
4109    pub(crate) async fn handle_piece_data(
4110        &mut self,
4111        peer_addr: SocketAddr,
4112        index: u32,
4113        begin: u32,
4114        data: Bytes,
4115    ) {
4116        // Skip duplicate blocks — in end-game mode or after timeout re-requests,
4117        // the same block may arrive from multiple peers. Writing it to the store
4118        // buffer would overwrite valid data that's pending verification.
4119        if let Some(ref ct) = self.chunk_tracker
4120            && ct.has_chunk(index, begin)
4121        {
4122            self.total_download += data.len() as u64 + 13;
4123            // Remove from pending_requests to free pipeline slots. Without this,
4124            // the peer accumulates phantom entries from already-verified pieces
4125            // and eventually has zero available pipeline slots — permanent stall.
4126            if let Some(peer) = self.peers.get_mut(&peer_addr) {
4127                peer.pending_requests.remove(index, begin);
4128            }
4129            // Remove from end-game tracker so pick_block won't return this
4130            // block again. The normal path calls block_received which does
4131            // this, but we skip that path for duplicates.
4132            if self.end_game.is_active() {
4133                self.end_game.block_received(index, begin, peer_addr);
4134            }
4135            // M75: Permit already returned by peer task on Piece receipt
4136            return;
4137        }
4138
4139        let data_len = data.len();
4140
4141        // M100: Deferred write via per-torrent writer task.
4142        if let Some(ref disk) = self.disk {
4143            disk.write_block_deferred(index, begin, data);
4144        }
4145
4146        self.downloaded += data_len as u64;
4147        self.total_download += data_len as u64 + 13; // payload + message header
4148        self.last_download = now_unix();
4149        self.need_save_resume = true;
4150
4151        // M93: Track piece ownership (actor learns about peer's CAS reservation via chunk arrival)
4152        if let Some(slab_idx) = self.peer_slab.slot_of(&peer_addr)
4153            && self.piece_owner.get(index as usize) == Some(&None)
4154        {
4155            self.piece_owner[index as usize] = Some(slab_idx);
4156            // M149: Track when piece started downloading
4157            if self.inflight_started.get(index as usize) == Some(&None) {
4158                self.inflight_started[index as usize] = Some(Instant::now());
4159            }
4160            // M103: Add to steal queue if piece has unrequested blocks
4161            if let (Some(sc), Some(bm)) = (&self.steal_candidates, &self.block_maps)
4162                && let Some(lengths) = &self.lengths
4163            {
4164                let total_blocks = lengths.chunks_in_piece(index);
4165                if bm.next_unrequested(index, total_blocks).is_some() {
4166                    sc.push(index);
4167                }
4168            }
4169        }
4170
4171        // Smart banning: track which peers contribute to each piece
4172        self.piece_contributors
4173            .entry(index)
4174            .or_default()
4175            .insert(peer_addr.ip());
4176
4177        let now = std::time::Instant::now();
4178        if let Some(peer) = self.peers.get_mut(&peer_addr) {
4179            peer.pending_requests.remove(index, begin);
4180            peer.download_bytes_window += data_len as u64;
4181            peer.download_bytes_total += data_len as u64;
4182            peer.pipeline
4183                .block_received(index, begin, data_len as u32, now);
4184            peer.last_data_received = Some(now);
4185            // Clear snub if snubbed
4186            if peer.snubbed {
4187                peer.snubbed = false;
4188            }
4189        }
4190        // M137: Backoff is now automatically reset by mark_live() in PeerStates.
4191
4192        // End-game: cancel this block on all other peers. The 200ms end-game
4193        // refill tick will re-stock freed peers — no reactive cascade needed.
4194        if self.end_game.is_active() {
4195            let cancels = self.end_game.block_received(index, begin, peer_addr);
4196            for (cancel_addr, ci, cb, cl) in cancels {
4197                if let Some(cancel_peer) = self.peers.get_mut(&cancel_addr) {
4198                    let _ = cancel_peer.cmd_tx.try_send(PeerCommand::Cancel {
4199                        index: ci,
4200                        begin: cb,
4201                        length: cl,
4202                    });
4203                    cancel_peer.pending_requests.remove(ci, cb);
4204                }
4205            }
4206        }
4207
4208        // Track chunk completion
4209        let piece_complete = if let Some(ref mut ct) = self.chunk_tracker {
4210            ct.chunk_received(index, begin)
4211        } else {
4212            false
4213        };
4214
4215        if piece_complete && !self.pending_verify.contains(&index) {
4216            // M44/M118: Predictive piece announce — broadcast Have before verification
4217            if self.config.predictive_piece_announce_ms > 0
4218                && !self.predictive_have_sent.contains(&index)
4219            {
4220                self.predictive_have_sent.insert(index);
4221                let _ = self.have_broadcast_tx.send(index);
4222            }
4223
4224            // M100: Flush deferred writes before verification — ensures all
4225            // blocks are on disk so read_piece() sees complete data.
4226            if let Some(ref disk) = self.disk {
4227                disk.flush_piece_writes(index).await;
4228            }
4229
4230            match self.version {
4231                irontide_core::TorrentVersion::V1Only => {
4232                    // Async: fire-and-forget, result via verify_result_rx
4233                    if let Some(ref disk) = self.disk
4234                        && let Some(expected) = self
4235                            .meta
4236                            .as_ref()
4237                            .and_then(|m| m.info.piece_hash(index as usize))
4238                    {
4239                        self.pending_verify.insert(index);
4240                        let generation = self
4241                            .piece_generations
4242                            .get(index as usize)
4243                            .copied()
4244                            .unwrap_or(0);
4245                        disk.enqueue_verify(index, expected, generation, &self.verify_result_tx);
4246                    }
4247                }
4248                irontide_core::TorrentVersion::V2Only => {
4249                    // Blocking: needs mutable hash_picker for Merkle tree
4250                    self.verify_and_mark_piece_v2(index).await;
4251                }
4252                irontide_core::TorrentVersion::Hybrid => {
4253                    // Blocking: needs both v1+v2 decision matrix
4254                    self.verify_and_mark_piece_hybrid(index).await;
4255                }
4256            }
4257        }
4258
4259        // M75: Permit already returned by peer task on Piece receipt.
4260        // End-game dispatch still happens here.
4261        if self.end_game.is_active() {
4262            self.request_end_game_block(peer_addr).await;
4263        }
4264    }
4265
4266    /// M92: Process a batch of block completions from a single peer.
4267    /// Iterates blocks, calling `process_block_completion()` for each.
4268    /// Piece verifications are triggered inline as pieces complete
4269    /// (same as the former per-block path).
4270    pub(crate) async fn handle_piece_blocks_batch(
4271        &mut self,
4272        peer_addr: SocketAddr,
4273        blocks: Vec<crate::types::BlockEntry>,
4274    ) {
4275        for block in &blocks {
4276            self.process_block_completion(peer_addr, block.index, block.begin, block.length)
4277                .await;
4278        }
4279    }
4280
4281    fn handle_open_file(
4282        &mut self,
4283        file_index: usize,
4284    ) -> crate::Result<crate::streaming::FileStreamHandle> {
4285        let meta = self
4286            .meta
4287            .as_ref()
4288            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4289        let files = meta.info.files();
4290        if file_index >= files.len() {
4291            return Err(crate::Error::InvalidFileIndex {
4292                index: file_index,
4293                count: files.len(),
4294            });
4295        }
4296        if self.file_priorities.get(file_index).copied() == Some(FilePriority::Skip) {
4297            return Err(crate::Error::FileSkipped { index: file_index });
4298        }
4299
4300        let lengths = self
4301            .lengths
4302            .as_ref()
4303            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4304        let disk = self
4305            .disk
4306            .as_ref()
4307            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
4308
4309        // Compute file offset within torrent data
4310        let mut file_offset = 0u64;
4311        for f in &files[..file_index] {
4312            file_offset += f.length;
4313        }
4314        let file_length = files[file_index].length;
4315
4316        let (cursor_tx, cursor_rx) = tokio::sync::watch::channel(0u64);
4317
4318        let permit = self
4319            .stream_read_semaphore
4320            .clone()
4321            .try_acquire_owned()
4322            .map_err(|_| crate::Error::Connection("too many concurrent stream readers".into()))?;
4323
4324        // Add streaming cursor for the actor to track
4325        self.streaming_cursors
4326            .push(crate::streaming::StreamingCursor {
4327                file_index,
4328                file_offset,
4329                cursor_piece: (file_offset / lengths.piece_length()) as u32,
4330                readahead_pieces: self.config.readahead_pieces,
4331                cursor_rx,
4332            });
4333
4334        Ok(crate::streaming::FileStreamHandle {
4335            disk: disk.clone(),
4336            lengths: lengths.clone(),
4337            file_index,
4338            file_offset,
4339            file_length,
4340            cursor_tx,
4341            piece_ready_rx: self.piece_ready_tx.subscribe(),
4342            have: self.have_watch_rx.clone(),
4343            read_permit: permit,
4344        })
4345    }
4346
4347    /// M44: Suggest cached pieces to connected peers (BEP 6).
4348    async fn suggest_cached_pieces(&mut self) {
4349        if !self.config.suggest_mode {
4350            return;
4351        }
4352        let disk = match self.disk {
4353            Some(ref d) => d.clone(),
4354            None => return,
4355        };
4356        let cached = disk.cached_pieces().await;
4357        if cached.is_empty() {
4358            return;
4359        }
4360        let max_suggest = self.config.max_suggest_pieces;
4361        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4362        for peer_addr in peer_addrs {
4363            let already_suggested = self.suggested_to_peers.entry(peer_addr).or_default();
4364            let peer_has_piece = |piece: u32| -> bool {
4365                self.peers
4366                    .get(&peer_addr)
4367                    .is_some_and(|p| p.bitfield.get(piece))
4368            };
4369            let mut sent = 0;
4370            for &piece in &cached {
4371                if sent >= max_suggest {
4372                    break;
4373                }
4374                if peer_has_piece(piece) {
4375                    continue;
4376                }
4377                if already_suggested.contains(&piece) {
4378                    continue;
4379                }
4380                if let Some(peer) = self.peers.get(&peer_addr) {
4381                    let _ = peer.cmd_tx.try_send(PeerCommand::SuggestPiece(piece));
4382                    already_suggested.insert(piece);
4383                    sent += 1;
4384                }
4385            }
4386        }
4387    }
4388
4389    /// M147: Handle pre-resolved metadata from the background resolver.
4390    ///
4391    /// If the `TorrentActor` is still in `FetchingMetadata` state, feed the
4392    /// info bytes through `MetadataDownloader` and call `try_assemble_metadata()`.
4393    /// If already past that state (actor resolved first), silently ignore.
4394    async fn handle_pre_resolved_metadata(&mut self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
4395        // Only act if still fetching metadata — actor may have resolved first.
4396        if self.state != TorrentState::FetchingMetadata {
4397            debug!(
4398                info_hash = %self.info_hash,
4399                state = ?self.state,
4400                "ignoring pre-resolved metadata: already past FetchingMetadata"
4401            );
4402            return;
4403        }
4404
4405        debug!(
4406            info_hash = %self.info_hash,
4407            info_bytes_len = info_bytes.len(),
4408            num_peers = peers.len(),
4409            "received pre-resolved metadata from background resolver"
4410        );
4411
4412        // Feed the complete info bytes to the MetadataDownloader.
4413        if let Some(ref mut dl) = self.metadata_downloader {
4414            // Set total size so the downloader knows the expected piece count.
4415            dl.set_total_size(info_bytes.len() as u64);
4416
4417            // Feed as a single piece (piece 0) containing the full info dict.
4418            // For metadata smaller than 16 KiB this is a single piece.
4419            // For larger metadata, feed each 16 KiB chunk as a separate piece.
4420            let piece_size: usize = 16384;
4421            let num_pieces = info_bytes.len().div_ceil(piece_size);
4422            for i in 0..num_pieces {
4423                let start = i * piece_size;
4424                let end = (start + piece_size).min(info_bytes.len());
4425                let data = bytes::Bytes::copy_from_slice(&info_bytes[start..end]);
4426                dl.piece_received(i as u32, data);
4427            }
4428        }
4429
4430        // Attempt assembly — this will transition to Downloading if
4431        // the info_hash validates.
4432        self.try_assemble_metadata().await;
4433
4434        // Pre-seed discovered peers into the pipeline.
4435        if !peers.is_empty() {
4436            self.handle_add_peers(peers, crate::peer_state::PeerSource::Dht);
4437        }
4438    }
4439
4440    pub(crate) async fn try_assemble_metadata(&mut self) {
4441        let assembled = if let Some(ref dl) = self.metadata_downloader {
4442            dl.assemble_and_verify()
4443        } else {
4444            return;
4445        };
4446
4447        match assembled {
4448            Ok(info_bytes) => {
4449                // Build torrent bytes wrapping the raw info dict into a minimal torrent
4450                // We need to parse it as a full torrent. The info_bytes is the raw bencoded
4451                // info dict. We'll build a minimal torrent around it.
4452                // Actually, torrent_from_bytes expects a full torrent dict.
4453                // Let's build one:
4454                let mut torrent_bytes = b"d4:info".to_vec();
4455                torrent_bytes.extend_from_slice(&info_bytes);
4456                torrent_bytes.push(b'e');
4457
4458                match torrent_from_bytes(&torrent_bytes) {
4459                    Ok(meta) => {
4460                        let num_pieces = meta.info.num_pieces() as u32;
4461                        let lengths = Lengths::new(
4462                            meta.info.total_length(),
4463                            meta.info.piece_length,
4464                            DEFAULT_CHUNK_SIZE,
4465                        );
4466
4467                        // Create filesystem storage now that we know the file layout
4468                        let files = meta.info.files();
4469                        let file_paths: Vec<std::path::PathBuf> = files
4470                            .iter()
4471                            .map(|f| f.path.iter().collect::<std::path::PathBuf>())
4472                            .collect();
4473                        let file_lengths_vec: Vec<u64> = files.iter().map(|f| f.length).collect();
4474                        let prealloc_mode = self.config.preallocate_mode.unwrap_or_else(|| {
4475                            irontide_storage::PreallocateMode::from(
4476                                self.config.storage_mode == irontide_core::StorageMode::Full,
4477                            )
4478                        });
4479                        let storage: Arc<dyn TorrentStorage> =
4480                            match irontide_storage::FilesystemStorage::new(
4481                                &self.config.download_dir,
4482                                file_paths,
4483                                file_lengths_vec,
4484                                lengths.clone(),
4485                                None,
4486                                prealloc_mode,
4487                                self.config.filesystem_direct_io,
4488                            ) {
4489                                Ok(s) => Arc::new(s),
4490                                Err(e) => {
4491                                    warn!(
4492                                        "failed to create filesystem storage: {e}, falling back to memory"
4493                                    );
4494                                    Arc::new(MemoryStorage::new(lengths.clone()))
4495                                }
4496                            };
4497                        let mut disk_handle = self
4498                            .disk_manager
4499                            .register_torrent(self.info_hash, storage)
4500                            .await;
4501
4502                        self.chunk_tracker = Some(ChunkTracker::new(lengths.clone()));
4503                        self.lengths = Some(lengths);
4504                        self.num_pieces = num_pieces;
4505                        // M96: Initialize real generation counters + hash result channel
4506                        self.piece_generations = vec![0u64; num_pieces as usize];
4507                        let (hash_tx, hash_rx) = tokio::sync::mpsc::channel(64);
4508                        self.hash_result_tx = hash_tx;
4509                        self.hash_result_rx = hash_rx;
4510                        // M96: Wire hash pool into disk handle (version check deferred
4511                        // until after metadata detection below sets self.version)
4512                        if let Some(ref pool) = self.hash_pool_ref {
4513                            disk_handle.set_hash_pool(pool.clone());
4514                            disk_handle.set_hash_result_tx(self.hash_result_tx.clone());
4515                        }
4516                        self.disk = Some(disk_handle);
4517                        // Update all connected peer tasks so they can validate
4518                        // incoming Bitfield messages with the correct piece count.
4519                        for peer in self.peers.values() {
4520                            let _ = peer
4521                                .cmd_tx
4522                                .try_send(PeerCommand::UpdateNumPieces(num_pieces));
4523                        }
4524                        let file_lengths: Vec<u64> =
4525                            meta.info.files().iter().map(|f| f.length).collect();
4526                        let mut meta = meta;
4527                        meta.info_bytes = Some(Bytes::from(info_bytes));
4528                        self.meta = Some(meta);
4529
4530                        // M116: Populate cached file info for zero-alloc completion checks.
4531                        if let (Some(meta), Some(lengths)) = (&self.meta, &self.lengths) {
4532                            self.cached_files = Some(build_cached_file_info(meta, lengths));
4533                        }
4534
4535                        self.file_priorities = vec![FilePriority::Normal; file_lengths.len()];
4536
4537                        // BEP 53: apply magnet so= file selection
4538                        if let Some(ref selections) = self.magnet_selected_files {
4539                            self.file_priorities = irontide_core::FileSelection::to_priorities(
4540                                selections,
4541                                file_lengths.len(),
4542                            );
4543                            self.magnet_selected_files = None;
4544                        }
4545
4546                        self.wanted_pieces = crate::piece_selector::build_wanted_pieces(
4547                            &self.file_priorities,
4548                            &file_lengths,
4549                            self.lengths.as_ref().unwrap(),
4550                        );
4551                        if self.config.share_mode {
4552                            self.transition_state(TorrentState::Sharing);
4553                        } else {
4554                            self.transition_state(TorrentState::Downloading);
4555                        }
4556                        self.metadata_downloader = None;
4557
4558                        // Populate tracker manager with newly parsed metadata
4559                        if let Some(ref meta) = self.meta {
4560                            self.tracker_manager
4561                                .set_metadata_filtered(meta, self.config.url_security);
4562                        }
4563
4564                        // Detect hybrid/v2 from metadata and update dual-swarm state
4565                        // (Gap 1 & 2: propagate info_hashes to tracker + DHT after magnet resolves)
4566                        if let Ok(detected) = irontide_core::torrent_from_bytes_any(&torrent_bytes)
4567                        {
4568                            let new_version = detected.version();
4569                            if new_version != irontide_core::TorrentVersion::V1Only {
4570                                let new_hashes = detected.info_hashes();
4571                                self.version = new_version;
4572                                self.info_hashes = new_hashes.clone();
4573                                self.tracker_manager.set_info_hashes(new_hashes.clone());
4574                                if let Some(v2_meta) = detected.as_v2() {
4575                                    self.meta_v2 = Some(v2_meta.clone());
4576                                }
4577                                // Start v2 DHT lookups for hybrid torrents
4578                                if new_hashes.is_hybrid()
4579                                    && let Some(v2) = new_hashes.v2
4580                                {
4581                                    let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
4582                                    if v2_as_v1 != self.info_hash {
4583                                        if self.dht_v2_peers_rx.is_none()
4584                                            && let Some(dht) = self.current_dht()
4585                                            && let Ok(rx) = dht.get_peers(v2_as_v1).await
4586                                        {
4587                                            self.dht_v2_peers_rx = Some(rx);
4588                                        }
4589                                        if self.dht_v6_v2_peers_rx.is_none()
4590                                            && self.dht_v6_empty_count < 30
4591                                            && self.should_retry_v6()
4592                                            && let Some(dht6) = self.current_dht_v6()
4593                                            && let Ok(rx) = dht6.get_peers(v2_as_v1).await
4594                                        {
4595                                            self.dht_v6_last_retry =
4596                                                Some(std::time::Instant::now());
4597                                            self.dht_v6_v2_peers_rx = Some(rx);
4598                                        }
4599                                    }
4600                                }
4601                            }
4602                        }
4603
4604                        let name = self
4605                            .meta
4606                            .as_ref()
4607                            .map(|m| m.info.name.clone())
4608                            .unwrap_or_default();
4609                        post_alert(
4610                            &self.alert_tx,
4611                            &self.alert_mask,
4612                            AlertKind::MetadataReceived {
4613                                info_hash: self.info_hash,
4614                                name,
4615                            },
4616                        );
4617                        info!("metadata assembled, switching to Downloading");
4618
4619                        // M93: Initialize lock-free piece states after metadata
4620                        if let Some(ct) = &self.chunk_tracker {
4621                            let atomic_states = Arc::new(AtomicPieceStates::new(
4622                                self.num_pieces,
4623                                ct.bitfield(),
4624                                &self.wanted_pieces,
4625                            ));
4626                            self.atomic_states = Some(Arc::clone(&atomic_states));
4627                            self.piece_owner = vec![None; self.num_pieces as usize];
4628                            // M149: Initialize inflight tracking
4629                            self.inflight_started = vec![None; self.num_pieces as usize];
4630                            self.max_in_flight = self.config.max_in_flight_pieces;
4631
4632                            // M103: Initialize block stealing infrastructure
4633                            if self.config.use_block_stealing {
4634                                if let Some(ref lengths) = self.lengths {
4635                                    self.block_maps =
4636                                        Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
4637                                }
4638                                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
4639                            }
4640                            // M120: Per-piece write guards
4641                            self.piece_write_guards = Some(Arc::new(
4642                                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
4643                            ));
4644
4645                            // M187: Init direct-acquire dispatch state.
4646                            self.piece_tracker = Some(PieceTracker::new(
4647                                self.num_pieces,
4648                                ct.bitfield(),
4649                                &self.wanted_pieces,
4650                            ));
4651                            if let Some(ref cached) = self.cached_files {
4652                                let file_piece_ranges: Vec<(u32, u32)> = cached
4653                                    .entries
4654                                    .iter()
4655                                    .map(|e| (e.first_piece, e.last_piece))
4656                                    .collect();
4657                                let om = Arc::new(PieceOrderMap::build(
4658                                    &self.file_priorities,
4659                                    &file_piece_ranges,
4660                                    self.num_pieces,
4661                                    0,
4662                                ));
4663                                self.order_map_tx.send_replace(om);
4664                            }
4665
4666                            let notify = Arc::new(tokio::sync::Notify::new());
4667                            self.reservation_notify = Some(notify);
4668                        }
4669
4670                        // Start web seeds now that we have metadata
4671                        self.spawn_web_seeds();
4672                        self.assign_pieces_to_web_seeds();
4673
4674                        // Kick-start piece requesting for all peers that connected during
4675                        // metadata phase. Send StartRequesting to all connected peers.
4676                        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
4677                        info!(
4678                            connected_peers = peer_addrs.len(),
4679                            "kick-starting piece requests for pre-connected peers"
4680                        );
4681                        for addr in peer_addrs {
4682                            let has_bitfield =
4683                                self.peers.get(&addr).map_or(0, |p| p.bitfield.count_ones());
4684                            let is_choking = self.peers.get(&addr).is_none_or(|p| p.peer_choking);
4685                            debug!(%addr, has_bitfield, is_choking, "post-metadata peer state");
4686                            self.maybe_express_interest(addr).await;
4687                            if let Some(peer) = self.peers.get(&addr)
4688                                && peer.bitfield.count_ones() > 0
4689                            {
4690                                let _slot = self.peer_slab.insert(addr);
4691                            }
4692                        }
4693                        self.recalc_max_in_flight();
4694                        // M93: Inform all connected peers about lock-free dispatch state.
4695                        // M159: Skip while user seed mode is active — we are currently
4696                        // not scheduling any new block requests.
4697                        if !self.user_seed_mode
4698                            && let Some(notify) = &self.reservation_notify
4699                            && let Some(ref lengths) = self.lengths
4700                        {
4701                            for peer in self.peers.values() {
4702                                let _ = peer.cmd_tx.try_send(PeerCommand::StartRequesting {
4703                                    piece_notify: Arc::clone(notify),
4704                                    disk_handle: self.disk.clone(),
4705                                    write_error_tx: self.write_error_tx.clone(),
4706                                    lengths: lengths.clone(),
4707                                });
4708                            }
4709                        }
4710                    }
4711                    Err(e) => {
4712                        warn!("failed to parse assembled metadata: {e}");
4713                        post_alert(
4714                            &self.alert_tx,
4715                            &self.alert_mask,
4716                            AlertKind::MetadataFailed {
4717                                info_hash: self.info_hash,
4718                            },
4719                        );
4720                    }
4721                }
4722            }
4723            Err(e) => {
4724                warn!("metadata assembly failed: {e}");
4725                post_alert(
4726                    &self.alert_tx,
4727                    &self.alert_mask,
4728                    AlertKind::MetadataFailed {
4729                        info_hash: self.info_hash,
4730                    },
4731                );
4732            }
4733        }
4734    }
4735
4736    // ----- Web seeding (M22) -----
4737
4738    fn spawn_web_seeds(&mut self) {
4739        if !self.config.enable_web_seed {
4740            return;
4741        }
4742        let Some(meta) = &self.meta else { return };
4743        let lengths = match &self.lengths {
4744            Some(l) => l.clone(),
4745            None => return,
4746        };
4747
4748        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
4749        let file_map = irontide_storage::FileMap::new(file_lengths, lengths.clone());
4750
4751        // BEP 19 (GetRight) web seeds
4752        for url in &meta.url_list {
4753            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4754                continue;
4755            }
4756            if self.web_seeds.len() >= self.config.max_web_seeds {
4757                break;
4758            }
4759
4760            // Security validation
4761            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4762                warn!(%url, %e, "web seed URL rejected by security policy");
4763                continue;
4764            }
4765
4766            let url_builder = if meta.info.length.is_some() {
4767                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone())
4768            } else {
4769                let file_paths: Vec<String> = meta
4770                    .info
4771                    .files()
4772                    .iter()
4773                    .map(|f| f.path[1..].join("/")) // skip torrent name prefix
4774                    .collect();
4775                crate::web_seed::WebSeedUrlBuilder::multi(
4776                    url.clone(),
4777                    meta.info.name.clone(),
4778                    file_paths,
4779                )
4780            };
4781
4782            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4783            let initial_downloaded = self
4784                .web_seed_stats
4785                .get(url)
4786                .map_or(0, |s| s.downloaded_bytes);
4787            let task = crate::web_seed::WebSeedTask::new(
4788                url.clone(),
4789                crate::web_seed::WebSeedMode::GetRight,
4790                url_builder,
4791                lengths.clone(),
4792                file_map.clone(),
4793                self.info_hash,
4794                cmd_rx,
4795                self.event_tx.clone(),
4796                self.config.url_security,
4797                self.config.web_seed_progress_throttle_ms,
4798                initial_downloaded,
4799                self.config.web_seed_retry_base_secs,
4800                self.config.web_seed_retry_factor,
4801                self.config.web_seed_retry_cap_secs,
4802                self.config.web_seed_max_failures,
4803            );
4804            tokio::spawn(task.run());
4805            self.web_seeds.insert(url.clone(), cmd_tx);
4806            debug!(url, "spawned BEP 19 web seed");
4807        }
4808
4809        // BEP 17 (Hoffman) HTTP seeds
4810        for url in &meta.httpseeds {
4811            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
4812                continue;
4813            }
4814            if self.web_seeds.len() >= self.config.max_web_seeds {
4815                break;
4816            }
4817
4818            // Security validation
4819            if let Err(e) = crate::url_guard::validate_web_seed_url(url, self.config.url_security) {
4820                warn!(%url, %e, "web seed URL rejected by security policy");
4821                continue;
4822            }
4823
4824            // BEP 17 doesn't use URL builder for per-file paths; it sends parameterized URLs
4825            let url_builder =
4826                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone());
4827
4828            let (cmd_tx, cmd_rx) = mpsc::channel(16);
4829            let initial_downloaded = self
4830                .web_seed_stats
4831                .get(url)
4832                .map_or(0, |s| s.downloaded_bytes);
4833            let task = crate::web_seed::WebSeedTask::new(
4834                url.clone(),
4835                crate::web_seed::WebSeedMode::Hoffman,
4836                url_builder,
4837                lengths.clone(),
4838                file_map.clone(),
4839                self.info_hash,
4840                cmd_rx,
4841                self.event_tx.clone(),
4842                self.config.url_security,
4843                self.config.web_seed_progress_throttle_ms,
4844                initial_downloaded,
4845                self.config.web_seed_retry_base_secs,
4846                self.config.web_seed_retry_factor,
4847                self.config.web_seed_retry_cap_secs,
4848                self.config.web_seed_max_failures,
4849            );
4850            tokio::spawn(task.run());
4851            self.web_seeds.insert(url.clone(), cmd_tx);
4852            debug!(url, "spawned BEP 17 web seed");
4853        }
4854    }
4855
4856    pub(crate) fn assign_pieces_to_web_seeds(&mut self) {
4857        if self.state != TorrentState::Downloading || self.end_game.is_active() {
4858            return;
4859        }
4860
4861        // Collect idle web seed URLs (not currently downloading a piece)
4862        let active_urls: HashSet<&String> = self.web_seed_in_flight.values().collect();
4863        let idle_urls: Vec<String> = self
4864            .web_seeds
4865            .keys()
4866            .filter(|u| !active_urls.contains(u))
4867            .cloned()
4868            .collect();
4869
4870        let Some(ct) = &self.chunk_tracker else {
4871            return;
4872        };
4873
4874        for url in idle_urls {
4875            // Find lowest-index piece that is: not verified, not reserved by a peer,
4876            // not in web_seed_in_flight, and wanted.
4877            let piece = (0..self.num_pieces).find(|&i| {
4878                !ct.has_piece(i)
4879                    && !self
4880                        .piece_owner
4881                        .get(i as usize)
4882                        .is_some_and(std::option::Option::is_some)
4883                    && !self.web_seed_in_flight.contains_key(&i)
4884                    && self.wanted_pieces.get(i)
4885            });
4886
4887            if let Some(piece) = piece
4888                && let Some(cmd_tx) = self.web_seeds.get(&url)
4889            {
4890                let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::FetchPiece(piece));
4891                self.web_seed_in_flight.insert(piece, url);
4892            }
4893        }
4894    }
4895
4896    pub(crate) async fn handle_web_seed_piece_data(
4897        &mut self,
4898        url: String,
4899        index: u32,
4900        data: Bytes,
4901    ) {
4902        self.web_seed_in_flight.remove(&index);
4903
4904        // If peer already completed this piece, discard
4905        if let Some(ref ct) = self.chunk_tracker
4906            && ct.has_piece(index)
4907        {
4908            self.assign_pieces_to_web_seeds();
4909            return;
4910        }
4911
4912        // Write entire piece to disk at offset 0
4913        if let Some(ref disk) = self.disk
4914            && let Err(e) = disk
4915                .write_chunk(index, 0, data.clone(), DiskJobFlags::FLUSH_PIECE)
4916                .await
4917        {
4918            warn!(index, "web seed: failed to write piece: {e}");
4919            self.assign_pieces_to_web_seeds();
4920            return;
4921        }
4922
4923        // Mark all chunks as received
4924        if let Some(ref mut ct) = self.chunk_tracker
4925            && let Some(ref lengths) = self.lengths
4926        {
4927            let num_chunks = lengths.chunks_in_piece(index);
4928            for chunk_idx in 0..num_chunks {
4929                if let Some((begin, _len)) = lengths.chunk_info(index, chunk_idx) {
4930                    ct.chunk_received(index, begin);
4931                }
4932            }
4933        }
4934
4935        self.downloaded += data.len() as u64;
4936        self.total_download += data.len() as u64 + 13; // payload + message header
4937        self.last_download = now_unix();
4938        self.need_save_resume = true;
4939
4940        // Verify the piece hash
4941        self.verify_and_mark_piece(index).await;
4942
4943        // If hash failed, ban this web seed (BEP 19 spec)
4944        if let Some(ref ct) = self.chunk_tracker
4945            && !ct.has_piece(index)
4946        {
4947            self.ban_web_seed(&url);
4948            return;
4949        }
4950
4951        self.assign_pieces_to_web_seeds();
4952    }
4953
4954    pub(crate) fn handle_web_seed_error(&mut self, url: &str, piece: u32, message: &str) {
4955        self.web_seed_in_flight.remove(&piece);
4956        warn!(%url, piece, %message, "web seed error");
4957        self.assign_pieces_to_web_seeds();
4958    }
4959
4960    /// M178: Update per-URL `WebSeedStats` from a `WebSeedProgress` event.
4961    ///
4962    /// State machine: Idle → Active on first success; Active → Errored on
4963    /// failure; Errored → Active on recovery (`last_error` PERSISTS through
4964    /// recovery per Issue 2.2 / D-eng-8). `consecutive_failures` increments
4965    /// monotonically within a failure run and resets to zero on success.
4966    /// `last_attempt_unix_secs` updates on every event regardless of outcome.
4967    pub(crate) fn handle_web_seed_progress(
4968        &mut self,
4969        url: &str,
4970        bytes: u64,
4971        rate_bps: u64,
4972        error: Option<String>,
4973    ) {
4974        let now_unix = std::time::SystemTime::now()
4975            .duration_since(std::time::UNIX_EPOCH)
4976            .map_or(0, |d| d.as_secs());
4977        let entry = self
4978            .web_seed_stats
4979            .entry(url.to_owned())
4980            .or_insert_with(|| irontide_core::WebSeedStats {
4981                url: url.to_owned(),
4982                ..Default::default()
4983            });
4984        entry.downloaded_bytes = bytes;
4985        entry.last_rate_bps = rate_bps;
4986        entry.last_attempt_unix_secs = now_unix;
4987        if let Some(msg) = error {
4988            entry.state = irontide_core::WebSeedState::Errored;
4989            entry.last_error = Some(msg);
4990            entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
4991            // M186: Populate next_retry_unix_secs during backoff
4992            let attempt = entry.consecutive_failures.saturating_sub(1);
4993            let secs = self
4994                .config
4995                .web_seed_retry_base_secs
4996                .saturating_mul(self.config.web_seed_retry_factor.saturating_pow(attempt))
4997                .min(self.config.web_seed_retry_cap_secs);
4998            entry.next_retry_unix_secs = Some(now_unix + secs);
4999        } else {
5000            entry.state = irontide_core::WebSeedState::Active;
5001            entry.consecutive_failures = 0;
5002            entry.next_retry_unix_secs = None;
5003        }
5004        self.need_save_resume = true;
5005    }
5006
5007    pub(crate) fn ban_web_seed(&mut self, url: &str) {
5008        warn!(%url, "banning web seed due to hash failure");
5009        self.banned_web_seeds.insert(url.to_owned());
5010
5011        // Send shutdown to the task
5012        if let Some(cmd_tx) = self.web_seeds.remove(url) {
5013            let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::Shutdown);
5014        }
5015
5016        // Remove all in-flight pieces for this URL
5017        self.web_seed_in_flight.retain(|_, v| v != url);
5018
5019        post_alert(
5020            &self.alert_tx,
5021            &self.alert_mask,
5022            AlertKind::WebSeedBanned {
5023                info_hash: self.info_hash,
5024                url: url.to_owned(),
5025            },
5026        );
5027    }
5028
5029    async fn shutdown_web_seeds(&mut self) {
5030        for (_, cmd_tx) in self.web_seeds.drain() {
5031            let _ = cmd_tx.send(crate::web_seed::WebSeedCommand::Shutdown).await;
5032        }
5033        self.web_seed_in_flight.clear();
5034    }
5035
5036    /// Rebuild the cached peer rates map from current peer state.
5037    fn refresh_peer_rates(&mut self) {
5038        self.cached_peer_rates.clear();
5039        self.cached_peer_rates.reserve(self.peers.len());
5040        for (&addr, p) in &self.peers {
5041            self.cached_peer_rates.insert(addr, p.pipeline.ewma_rate());
5042        }
5043    }
5044
5045    // ----- Choking -----
5046
5047    fn update_peer_rates(&mut self) {
5048        for peer in self.peers.values_mut() {
5049            peer.download_rate = peer.download_bytes_window / 2;
5050            peer.upload_rate = peer.upload_bytes_window / 2;
5051            peer.download_bytes_window = 0;
5052            peer.upload_bytes_window = 0;
5053        }
5054
5055        // Track peak download rate for peer turnover cutoff
5056        let aggregate_download: u64 = self.peers.values().map(|p| p.download_rate).sum();
5057        if aggregate_download > self.peak_download_rate {
5058            self.peak_download_rate = aggregate_download;
5059        }
5060    }
5061
5062    async fn run_choker(&mut self) {
5063        let peer_infos: Vec<ChokerPeerInfo> = self
5064            .peers
5065            .values()
5066            .map(|p| ChokerPeerInfo {
5067                addr: p.addr,
5068                download_rate: p.download_rate,
5069                upload_rate: p.upload_rate,
5070                interested: p.peer_interested,
5071                upload_only: p.upload_only,
5072                is_seed: p.upload_only
5073                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5074            })
5075            .collect();
5076
5077        let decision = self.choker.decide(&peer_infos);
5078
5079        for addr in &decision.to_unchoke {
5080            if let Some(peer) = self.peers.get_mut(addr)
5081                && peer.am_choking
5082            {
5083                peer.am_choking = false;
5084                // Track unchoke window for fairness measurement.
5085                if peer.am_unchoke_started_at.is_none() {
5086                    peer.am_unchoke_started_at = Some(Instant::now());
5087                }
5088                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(false));
5089            }
5090        }
5091
5092        for addr in &decision.to_choke {
5093            if let Some(peer) = self.peers.get_mut(addr)
5094                && !peer.am_choking
5095            {
5096                if peer.supports_fast {
5097                    let pending: Vec<(u32, u32, u32)> = peer.incoming_requests.drain(..).collect();
5098                    for (index, begin, length) in pending {
5099                        let _ = peer.cmd_tx.try_send(PeerCommand::RejectRequest {
5100                            index,
5101                            begin,
5102                            length,
5103                        });
5104                    }
5105                }
5106                peer.am_choking = true;
5107                // Accumulate the unchoke window we just closed.
5108                if let Some(start) = peer.am_unchoke_started_at.take() {
5109                    peer.unchoke_duration_total += start.elapsed();
5110                }
5111                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(true));
5112            }
5113        }
5114
5115        // Serve any buffered requests from newly-unchoked peers
5116        self.serve_incoming_requests().await;
5117
5118        // Zombie pruning: disconnect peers with empty bitfields after 30s.
5119        // These peers consume connection slots but contribute no pieces.
5120        // Only prune during downloading — when seeding, empty-bitfield peers
5121        // are leechers we want to upload to.
5122        if self.state == TorrentState::Downloading {
5123            let zombie_threshold = Duration::from_secs(30);
5124            let zombies: Vec<SocketAddr> = self
5125                .peers
5126                .values()
5127                .filter(|p| {
5128                    p.bitfield.count_ones() == 0 && p.connected_at.elapsed() > zombie_threshold
5129                })
5130                .map(|p| p.addr)
5131                .collect();
5132
5133            for &addr in &zombies {
5134                debug!(%addr, "disconnecting zombie peer (empty bitfield after 30s)");
5135                self.disconnect_peer(addr, "zombie peer (empty bitfield)");
5136            }
5137            if !zombies.is_empty() {
5138                self.recalc_max_in_flight();
5139            }
5140        }
5141    }
5142
5143    fn rotate_optimistic(&mut self) {
5144        let peer_infos: Vec<ChokerPeerInfo> = self
5145            .peers
5146            .values()
5147            .map(|p| ChokerPeerInfo {
5148                addr: p.addr,
5149                download_rate: p.download_rate,
5150                upload_rate: p.upload_rate,
5151                interested: p.peer_interested,
5152                upload_only: p.upload_only,
5153                is_seed: p.upload_only
5154                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
5155            })
5156            .collect();
5157
5158        self.choker.rotate_optimistic(&peer_infos);
5159    }
5160
5161    /// Handle an incoming I2P peer connection (M41).
5162    ///
5163    /// Assigns a synthetic `SocketAddr` (from the reserved 240.0.0.0/4 range) since
5164    /// I2P peers don't have real IP addresses, then hands the underlying TCP stream
5165    /// to `spawn_peer_from_stream`.
5166    fn handle_i2p_incoming(&mut self, stream: crate::i2p::SamStream) {
5167        if self.peers.len() >= self.effective_max_connections() {
5168            return;
5169        }
5170
5171        let synthetic_addr = self.next_i2p_synthetic_addr();
5172
5173        let remote_dest = stream.remote_destination().clone();
5174        let dest_preview = {
5175            let b64 = remote_dest.to_base64();
5176            if b64.len() >= 8 {
5177                b64[..8].to_string()
5178            } else {
5179                b64
5180            }
5181        };
5182        self.i2p_destinations.insert(synthetic_addr, remote_dest);
5183        let tcp_stream = stream.into_inner();
5184
5185        self.spawn_peer_from_stream(synthetic_addr, tcp_stream);
5186
5187        debug!(dest = %dest_preview, addr = %synthetic_addr, "accepted I2P peer");
5188    }
5189
5190    /// Add an I2P peer by destination, assigning a synthetic `SocketAddr`.
5191    #[allow(dead_code)] // Used by Task 2 (outbound I2P connects)
5192    fn add_i2p_peer(
5193        &mut self,
5194        dest: crate::i2p::I2pDestination,
5195        source: PeerSource,
5196    ) -> Option<SocketAddr> {
5197        // Dedup: check if we already track this destination
5198        if self.i2p_destinations.values().any(|d| d == &dest) {
5199            return None;
5200        }
5201        let addr = self.next_i2p_synthetic_addr();
5202        self.i2p_destinations.insert(addr, dest);
5203        // M137: Track via unified PeerStates lifecycle
5204        if let Some(ref ps) = self.peer_states {
5205            ps.add_if_not_seen(addr, source);
5206        }
5207        Some(addr)
5208    }
5209
5210    /// Generate a unique synthetic `SocketAddr` for an I2P peer.
5211    ///
5212    /// Uses addresses from 240.0.0.0/4 (reserved, never routable) to avoid
5213    /// conflicts with real peers. The counter ensures uniqueness across the
5214    /// torrent's lifetime.
5215    fn next_i2p_synthetic_addr(&mut self) -> SocketAddr {
5216        self.i2p_peer_counter = self.i2p_peer_counter.wrapping_add(1);
5217        let a = ((self.i2p_peer_counter >> 16) & 0x0F) as u8 | 0xF0;
5218        let b = ((self.i2p_peer_counter >> 8) & 0xFF) as u8;
5219        let c = (self.i2p_peer_counter & 0xFF) as u8;
5220        SocketAddr::new(
5221            std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, 1)),
5222            (self.i2p_peer_counter & 0xFFFF) as u16,
5223        )
5224    }
5225}
5226
5227/// Check whether a `SocketAddr` uses a synthetic I2P address (240.0.0.0/4 range).
5228pub(crate) fn is_i2p_synthetic_addr(addr: &SocketAddr) -> bool {
5229    match addr {
5230        SocketAddr::V4(v4) => v4.ip().octets()[0] & 0xF0 == 0xF0,
5231        SocketAddr::V6(_) => false,
5232    }
5233}
5234
5235/// Helper to accept a connection from an optional transport listener.
5236/// Returns `pending` if no listener is bound, so the `select!` branch is skipped.
5237async fn accept_incoming(
5238    listener: &mut Option<Box<dyn crate::transport::TransportListener>>,
5239) -> std::io::Result<(crate::transport::BoxedStream, SocketAddr)> {
5240    match listener {
5241        Some(l) => l.accept().await,
5242        None => std::future::pending().await,
5243    }
5244}
5245
5246/// Helper to receive an incoming I2P connection from the accept loop channel.
5247/// Returns `pending` if I2P is not enabled, so the `select!` branch is skipped.
5248async fn accept_i2p(
5249    rx: &mut Option<mpsc::Receiver<crate::i2p::SamStream>>,
5250) -> Option<crate::i2p::SamStream> {
5251    match rx {
5252        Some(rx) => rx.recv().await,
5253        None => std::future::pending().await,
5254    }
5255}
5256
5257// ============================================================================
5258// BEP 52 hash serving (M87)
5259// ============================================================================
5260
5261/// Determine what to serve for a BEP 52 hash request.
5262///
5263/// Returns `Some(hashes)` to serve, or `None` to reject.
5264/// Only serves piece-layer hashes (the layer stored in `piece_layers`).
5265/// Block-layer or other layer requests are rejected since we don't store
5266/// the full Merkle tree.
5267pub(crate) fn serve_hashes(
5268    meta_v2: Option<&irontide_core::TorrentMetaV2>,
5269    version: irontide_core::TorrentVersion,
5270    lengths: Option<&Lengths>,
5271    request: &irontide_core::HashRequest,
5272) -> Option<Vec<irontide_core::Id32>> {
5273    // Reject if v1-only or no v2 metadata
5274    let meta_v2 = match meta_v2 {
5275        Some(m) if version != irontide_core::TorrentVersion::V1Only => m,
5276        _ => return None,
5277    };
5278
5279    // Look up piece-layer hashes for the requested file root
5280    let piece_hashes = meta_v2.file_piece_hashes(&request.file_root)?;
5281
5282    // We need lengths to validate the request geometry
5283    let lengths = lengths?;
5284
5285    // Compute per-file block count from piece hashes and piece/chunk sizes.
5286    // Each piece hash covers `piece_length / chunk_size` blocks, except the
5287    // last piece which may cover fewer. For validation purposes we use the
5288    // padded count that `validate_hash_request` expects.
5289    let blocks_per_piece = (meta_v2.info.piece_length / u64::from(lengths.chunk_size())) as u32;
5290    let num_pieces = piece_hashes.len() as u32;
5291    let num_blocks = num_pieces.saturating_mul(blocks_per_piece);
5292
5293    if !irontide_core::validate_hash_request(request, num_blocks, num_pieces) {
5294        return None;
5295    }
5296
5297    // We only have piece-layer hashes. The piece layer is at
5298    // base = log2(blocks_per_piece). Reject requests for other layers.
5299    let piece_layer_base = blocks_per_piece.trailing_zeros();
5300    if request.base != piece_layer_base {
5301        return None;
5302    }
5303
5304    // Extract requested hashes from the piece layer
5305    let start = request.index as usize;
5306    let end = (start + request.count as usize).min(piece_hashes.len());
5307    let mut hashes: Vec<irontide_core::Id32> = piece_hashes[start..end].to_vec();
5308
5309    // Compute proof (uncle) hashes if requested.
5310    //
5311    // BEP 52 specifies a single subtree proof for the entire batch, not
5312    // per-leaf proofs. The receiver rebuilds the subtree root from the
5313    // base hashes itself, so we skip the first `log2(count)` levels of
5314    // the proof path (those are internal to the requested subtree) and
5315    // only send the uncle hashes above it.
5316    if request.proof_layers > 0 && !piece_hashes.is_empty() {
5317        let tree = irontide_core::MerkleTree::from_leaves(&piece_hashes);
5318        let full_proof = tree.proof_path(start);
5319        // Skip levels internal to the requested subtree
5320        let subtree_depth = if request.count > 1 {
5321            (request.count as usize)
5322                .next_power_of_two()
5323                .trailing_zeros() as usize
5324        } else {
5325            0
5326        };
5327        let available = full_proof.len().saturating_sub(subtree_depth);
5328        let proof_count = (request.proof_layers as usize).min(available);
5329        hashes.extend_from_slice(&full_proof[subtree_depth..subtree_depth + proof_count]);
5330    }
5331
5332    Some(hashes)
5333}
5334
5335// ============================================================================
5336// Test-only constructors
5337// ============================================================================
5338
5339#[cfg(test)]
5340impl TorrentActor {
5341    /// v0.173.3 (A4): Build a minimal `TorrentActor` exercising only the
5342    /// fields touched by `rebuild_availability_snapshot`.
5343    ///
5344    /// Every other field is filled with the cheapest valid placeholder
5345    /// (empty channels, zero atomics, no-op handles). The actor is **not**
5346    /// spawned via `tokio::spawn` so it has no live `run()` loop — the
5347    /// returned struct is suitable for direct method-level testing only.
5348    ///
5349    /// `num_pieces` controls the size of the pre-allocated availability
5350    /// vector and atomic-states bitmap. `throttle_ms` plumbs the v0.173.3
5351    /// throttle config into the synthetic actor's `TorrentConfig`.
5352    ///
5353    /// Must run inside a tokio runtime because `DiskManagerHandle::new`
5354    /// internally spawns its background actor.
5355    pub(crate) fn for_throttle_test(num_pieces: u32, _throttle_ms: u64) -> Self {
5356        use irontide_storage::Bitfield;
5357
5358        let config = TorrentConfig {
5359            ..TorrentConfig::default()
5360        };
5361
5362        let info_hash = Id20([0u8; 20]);
5363        let our_peer_id = Id20([0u8; 20]);
5364
5365        let (_cmd_tx, cmd_rx) = mpsc::channel(1);
5366        let (event_tx, event_rx) = mpsc::channel(1);
5367        let (write_error_tx, write_error_rx) = mpsc::channel(1);
5368        let (verify_result_tx, verify_result_rx) = mpsc::channel(1);
5369        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
5370        let (piece_ready_tx, _piece_ready_rx) = broadcast::channel(1);
5371        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(num_pieces));
5372        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
5373        let (alert_tx, _alert_rx) = broadcast::channel(64);
5374        let (_disk_mgr_tx, _disk_mgr_rx) = mpsc::channel::<crate::disk::DiskJob>(1);
5375
5376        let stream_read_semaphore = Arc::new(tokio::sync::Semaphore::new(8));
5377        let alert_mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5378
5379        // DiskManagerHandle::new spawns an actor — requires runtime.
5380        let (disk_manager, _disk_join) =
5381            crate::disk::DiskManagerHandle::new(crate::disk::DiskConfig::default());
5382
5383        let ban_manager = Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5384            crate::ban::BanConfig::default(),
5385        )));
5386        let ip_filter = Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
5387
5388        let upload_bucket = crate::rate_limiter::TokenBucket::new(0);
5389        let download_bucket = Arc::new(parking_lot::Mutex::new(
5390            crate::rate_limiter::TokenBucket::new(0),
5391        ));
5392        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(0, 0, 0, 0, 0, 0);
5393
5394        let dht_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5395        let dht_v6_rx = irontide_dht::DhtBroadcast::new(None).subscribe();
5396        let factory = Arc::new(crate::transport::NetworkFactory::tokio());
5397
5398        // Atomic states + availability sized for `num_pieces`. The
5399        // availability snapshot rebuild reads both; everything else
5400        // (chunk tracker, file priorities, peers) can stay empty.
5401        let we_have = Bitfield::new(num_pieces);
5402        let mut wanted = Bitfield::new(num_pieces);
5403        for i in 0..num_pieces {
5404            wanted.set(i);
5405        }
5406        let atomic_states = Arc::new(crate::piece_reservation::AtomicPieceStates::new(
5407            num_pieces, &we_have, &wanted,
5408        ));
5409
5410        let (order_map_tx, _order_map_rx_seed) =
5411            tokio::sync::watch::channel(Arc::new(PieceOrderMap::empty()));
5412
5413        Self {
5414            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(0),
5415            config,
5416            info_hash,
5417            our_peer_id,
5418            state: TorrentState::Downloading,
5419            disk: None,
5420            disk_manager,
5421            chunk_tracker: None,
5422            lengths: None,
5423            num_pieces,
5424            file_priorities: Vec::new(),
5425            wanted_pieces: Bitfield::new(num_pieces),
5426            end_game: EndGame::new(),
5427            streaming_pieces: BTreeSet::new(),
5428            time_critical_pieces: BTreeSet::new(),
5429            streaming_cursors: Vec::new(),
5430            piece_ready_tx,
5431            have_watch_tx,
5432            have_watch_rx,
5433            stream_read_semaphore,
5434            peers: HashMap::new(),
5435            unchoke_durations: HashMap::new(),
5436            cached_peer_rates: FxHashMap::default(),
5437            refill_notify: Arc::new(tokio::sync::Notify::new()),
5438            atomic_states: Some(atomic_states),
5439            block_maps: None,
5440            steal_candidates: None,
5441            last_steal_populate: Instant::now(),
5442            piece_write_guards: None,
5443            soft_reap_buf: Vec::new(),
5444            eviction_history: std::collections::VecDeque::new(),
5445            force_immediate_choker_tick: false,
5446            piece_tracker: None,
5447            order_map_dirty: false,
5448            next_order_map_gen: 0,
5449            order_map_tx,
5450            piece_owner: vec![None; num_pieces as usize],
5451            peer_slab: crate::piece_reservation::PeerSlab::new(),
5452            priority_pieces: BTreeSet::new(),
5453            max_in_flight: 512,
5454            reservation_notify: None,
5455            last_tick_dispatch_state: None,
5456            choker: Choker::new(4),
5457            user_seed_mode: false,
5458            user_forced: false,
5459            max_connections: 0,
5460            peer_states: None,
5461            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
5462            connect_permits: HashMap::new(),
5463            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
5464                std::collections::HashMap::new(),
5465            )),
5466            connect_rx: None,
5467            metadata_downloader: None,
5468            meta: None,
5469            cached_files: None,
5470            downloaded: 0,
5471            uploaded: 0,
5472            checking_progress: 0.0,
5473            total_download: 0,
5474            total_upload: 0,
5475            total_failed_bytes: 0,
5476            total_redundant_bytes: 0,
5477            added_time: 0,
5478            completed_time: 0,
5479            last_download: 0,
5480            last_upload: 0,
5481            last_seen_complete: 0,
5482            active_duration: 0,
5483            finished_duration: 0,
5484            seeding_duration: 0,
5485            active_since: None,
5486            state_duration_since: None,
5487            started_at: Instant::now(),
5488            moving_storage: false,
5489            has_incoming: false,
5490            need_save_resume: false,
5491            error: String::new(),
5492            error_file: -1,
5493            cmd_rx,
5494            event_tx,
5495            event_rx,
5496            write_error_rx,
5497            write_error_tx,
5498            verify_result_rx,
5499            verify_result_tx,
5500            pending_verify: HashSet::new(),
5501            piece_generations: vec![0u64; num_pieces as usize],
5502            hash_result_rx,
5503            hash_result_tx,
5504            listener: None,
5505            utp_socket: None,
5506            utp_socket_v6: None,
5507            tracker_manager: TrackerManager::empty(info_hash, our_peer_id, 0, 0, false),
5508            tracker_result_rx: None,
5509            dht_rx,
5510            dht_v6_rx,
5511            dht_enabled: false,
5512            dht_peers_rx: None,
5513            dht_v6_peers_rx: None,
5514            dht_v6_empty_count: 0,
5515            dht_v6_last_retry: None,
5516            alert_tx,
5517            alert_mask,
5518            upload_bucket,
5519            download_bucket,
5520            global_upload_bucket: None,
5521            global_download_bucket: None,
5522            slot_tuner: crate::slot_tuner::SlotTuner::disabled(4),
5523            upload_bytes_interval: 0,
5524            peak_download_rate: 0,
5525            web_seeds: HashMap::new(),
5526            banned_web_seeds: HashSet::new(),
5527            web_seed_in_flight: HashMap::new(),
5528            web_seed_stats: HashMap::new(),
5529            pex_peer_count: 0,
5530            lsd_peer_count: 0,
5531            super_seed: None,
5532            have_broadcast_tx,
5533            suggested_to_peers: HashMap::new(),
5534            predictive_have_sent: HashSet::new(),
5535            ban_manager,
5536            piece_contributors: HashMap::new(),
5537            parole_pieces: HashMap::new(),
5538            ip_filter,
5539            external_ip: None,
5540            share_lru: std::collections::VecDeque::new(),
5541            share_max_pieces: 0,
5542            plugins: Arc::new(Vec::new()),
5543            hash_picker: None,
5544            version: irontide_core::TorrentVersion::V1Only,
5545            meta_v2: None,
5546            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
5547            dht_v2_peers_rx: None,
5548            dht_v6_v2_peers_rx: None,
5549            magnet_selected_files: None,
5550            sam_session: None,
5551            i2p_accept_rx: None,
5552            i2p_peer_counter: 0,
5553            i2p_destinations: HashMap::new(),
5554            ssl_manager: None,
5555            rate_limiter_set,
5556            auto_sequential_active: false,
5557            factory,
5558            hash_pool_ref: None,
5559            connect_attempts: 0,
5560            connect_failures: 0,
5561            choke_rotations: 0,
5562            inflight_started: Vec::new(),
5563            completed_piece_times: std::collections::VecDeque::new(),
5564            piece_steals: 0,
5565            holepunch_relayed: 0,
5566            holepunch_relay_rate: HashMap::new(),
5567            holepunch_cooldowns: HashMap::new(),
5568            holepunch_pending: Vec::new(),
5569            counters: Arc::new(crate::stats::SessionCounters::new()),
5570        }
5571    }
5572}
5573
5574// ============================================================================
5575// Tests
5576// ============================================================================
5577
5578#[cfg(test)]
5579mod tests {
5580    use super::*;
5581    use bytes::Bytes;
5582    use futures::{SinkExt, StreamExt};
5583    use irontide_wire::{ExtHandshake, Handshake, Message, MessageCodec};
5584    use std::time::Duration;
5585    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5586    use tokio::net::TcpListener;
5587    use tokio_util::codec::{FramedRead, FramedWrite};
5588
5589    // M224: initial unchoke slot derivation from Settings.max_uploads_per_torrent.
5590
5591    #[test]
5592    fn initial_unchoke_slots_unlimited_returns_default_four() {
5593        assert_eq!(initial_unchoke_slots(-1), 4);
5594    }
5595
5596    #[test]
5597    fn initial_unchoke_slots_capped_returns_value() {
5598        assert_eq!(initial_unchoke_slots(1), 1);
5599        assert_eq!(initial_unchoke_slots(4), 4);
5600        assert_eq!(initial_unchoke_slots(16), 16);
5601    }
5602
5603    // -- Helpers --
5604
5605    /// Build a valid `TorrentMetaV1` from raw data with given piece length.
5606    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
5607        use serde::Serialize;
5608
5609        #[derive(Serialize)]
5610        struct Info<'a> {
5611            length: u64,
5612            name: &'a str,
5613            #[serde(rename = "piece length")]
5614            piece_length: u64,
5615            #[serde(with = "serde_bytes")]
5616            pieces: &'a [u8],
5617        }
5618
5619        #[derive(Serialize)]
5620        struct Torrent<'a> {
5621            info: Info<'a>,
5622        }
5623
5624        let mut pieces = Vec::new();
5625        let mut offset = 0;
5626        while offset < data.len() {
5627            let end = (offset + piece_length as usize).min(data.len());
5628            let hash = irontide_core::sha1(&data[offset..end]);
5629            pieces.extend_from_slice(hash.as_bytes());
5630            offset = end;
5631        }
5632
5633        let t = Torrent {
5634            info: Info {
5635                length: data.len() as u64,
5636                name: "test",
5637                piece_length,
5638                pieces: &pieces,
5639            },
5640        };
5641
5642        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5643        torrent_from_bytes(&bytes).unwrap()
5644    }
5645
5646    fn test_config() -> TorrentConfig {
5647        TorrentConfig {
5648            listen_port: 0, // random port
5649            max_peers: 200,
5650            target_request_queue: 5,
5651            download_dir: std::path::PathBuf::from("/tmp"),
5652            enable_dht: false,
5653            enable_pex: false,
5654            enable_fast: false,
5655            seed_ratio_limit: None,
5656            seed_time_limit_secs: None,
5657            inactive_seed_time_limit_secs: None,
5658            strict_end_game: true,
5659            upload_rate_limit: 0,
5660            download_rate_limit: 0,
5661            max_uploads_per_torrent: -1,
5662            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
5663            enable_utp: false,
5664            enable_web_seed: true,
5665            enable_holepunch: false,
5666            enable_bep40_eviction: true,
5667            max_web_seeds: 4,
5668            web_seed_retry_base_secs: 10,
5669            web_seed_retry_factor: 6,
5670            web_seed_retry_cap_secs: 3600,
5671            web_seed_max_failures: 10,
5672            super_seeding: false,
5673            upload_only_announce: true,
5674            hashing_threads: 2,
5675            sequential_download: false,
5676            initial_picker_threshold: 4,
5677            whole_pieces_threshold: 20,
5678            snub_timeout_secs: 15,
5679            readahead_pieces: 8,
5680            streaming_timeout_escalation: true,
5681            max_concurrent_stream_reads: 8,
5682            proxy: crate::proxy::ProxyConfig::default(),
5683            anonymous_mode: false,
5684            share_mode: false,
5685            enable_i2p: false,
5686            allow_i2p_mixed: false,
5687            ssl_listen_port: 0,
5688            seed_choking_algorithm: crate::choker::SeedChokingAlgorithm::FastestUpload,
5689            choking_algorithm: crate::choker::ChokingAlgorithm::FixedSlots,
5690            piece_extent_affinity: true,
5691            suggest_mode: false,
5692            max_suggest_pieces: 10,
5693            predictive_piece_announce_ms: 0,
5694            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
5695            auto_sequential: true,
5696            storage_mode: irontide_core::StorageMode::Auto,
5697            preallocate_mode: None,
5698            block_request_timeout_secs: 60,
5699            enable_lsd: false,
5700            force_proxy: false,
5701            steal_threshold_ratio: 10.0,
5702            steal_threshold_endgame: 3.0,
5703            peer_read_timeout_secs: 0,         // disabled in tests
5704            peer_write_timeout_secs: 0,        // disabled in tests
5705            data_contribution_timeout_secs: 0, // disabled in tests
5706            // v0.187.3 eviction tunables — defaults that match production.
5707            pass0_grace_secs: 60,
5708            proactive_evictions_per_minute_limit: 30,
5709            eviction_ban_duration_secs: 600,
5710            eviction_ban_set_cap: 1024,
5711            choke_rotation_max_evictions: 0, // disabled in tests
5712            max_concurrent_connects: 128,
5713            connect_soft_timeout: 3,
5714            dispatch_backlog_cap: 8,
5715            event_backlog_cap: 32,
5716            peer_writer_channel_cap: 1024,
5717            use_actor_dispatch: true,
5718            web_seed_progress_throttle_ms: 250,
5719            url_security: crate::url_guard::UrlSecurityConfig::default(),
5720            peer_connect_timeout: 2,
5721            peer_dscp: 0x08,
5722            initial_queue_depth: 128,
5723            max_request_queue_depth: 250,
5724            request_queue_time: 3.0,
5725            max_metadata_size: 4 * 1024 * 1024,
5726            max_message_size: 16 * 1024 * 1024,
5727            max_piece_length: 32 * 1024 * 1024,
5728            max_outstanding_requests: 500,
5729            max_in_flight_pieces: 20,
5730            use_block_stealing: true,
5731            steal_stale_piece_secs: 2,
5732            fixed_pipeline_depth: 128,
5733            lock_warn_threshold_ms: 0, // disabled in tests
5734            filesystem_direct_io: false,
5735            category: None,
5736            tags: Vec::new(),
5737        }
5738    }
5739
5740    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5741        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5742        Arc::new(MemoryStorage::new(lengths))
5743    }
5744
5745    fn make_seeded_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
5746        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
5747        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
5748        // Write data piece by piece
5749        let num_pieces = lengths.num_pieces();
5750        for p in 0..num_pieces {
5751            let piece_size = lengths.piece_size(p) as usize;
5752            let offset = lengths.piece_offset(p) as usize;
5753            let end = offset + piece_size;
5754            storage.write_chunk(p, 0, &data[offset..end]).unwrap();
5755        }
5756        storage
5757    }
5758
5759    fn test_alert_channel() -> (broadcast::Sender<Alert>, Arc<AtomicU32>) {
5760        let (tx, _) = broadcast::channel(64);
5761        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
5762        (tx, mask)
5763    }
5764
5765    fn test_ban_manager() -> irontide_session_types::SharedBanManager {
5766        Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
5767            crate::ban::BanConfig::default(),
5768        )))
5769    }
5770
5771    fn test_ip_filter() -> irontide_session_types::SharedIpFilter {
5772        Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()))
5773    }
5774
5775    fn test_disk_manager() -> (DiskManagerHandle, tokio::task::JoinHandle<()>) {
5776        DiskManagerHandle::new(crate::disk::DiskConfig::default())
5777    }
5778
5779    async fn test_register_disk(
5780        info_hash: Id20,
5781        storage: Arc<dyn TorrentStorage>,
5782    ) -> (DiskHandle, DiskManagerHandle, tokio::task::JoinHandle<()>) {
5783        let (dm, join) = test_disk_manager();
5784        let dh = dm.register_torrent(info_hash, storage).await;
5785        (dh, dm, join)
5786    }
5787
5788    /// M173 Lane B (B6): build a `DhtReceiver` pre-populated with `None`
5789    /// — what the test fixtures previously passed as `dht: None`.
5790    fn test_dht_rx() -> irontide_dht::DhtReceiver {
5791        // `&'static` storage so each call returns a fresh subscriber
5792        // without leaking the underlying broadcast.
5793        let bx = irontide_dht::DhtBroadcast::new(None);
5794        bx.subscribe()
5795    }
5796
5797    /// Handshake size constant.
5798    const HANDSHAKE_SIZE: usize = 68;
5799
5800    // ---- Test 1: Create from torrent ----
5801
5802    #[tokio::test]
5803    async fn create_from_torrent() {
5804        let data = vec![0xAB; 32768]; // 32 KiB
5805        let meta = make_test_torrent(&data, 16384); // 2 pieces
5806        let storage = make_storage(&data, 16384);
5807        let config = test_config();
5808
5809        let (atx, amask) = test_alert_channel();
5810        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5811        let handle = TorrentHandle::from_torrent(
5812            meta,
5813            irontide_core::TorrentVersion::V1Only,
5814            None,
5815            dh,
5816            dm,
5817            config,
5818            test_dht_rx(),
5819            test_dht_rx(),
5820            None,
5821            None,
5822            crate::slot_tuner::SlotTuner::disabled(4),
5823            atx,
5824            amask,
5825            None,
5826            None,
5827            test_ban_manager(),
5828            test_ip_filter(),
5829            Arc::new(Vec::new()),
5830            None,
5831            None,
5832            Arc::new(crate::transport::NetworkFactory::tokio()),
5833            None, // M96: hash_pool
5834            Arc::new(crate::stats::SessionCounters::new()),
5835        )
5836        .await
5837        .unwrap();
5838
5839        let stats = handle.stats().await.unwrap();
5840        assert_eq!(stats.state, TorrentState::Downloading);
5841        assert_eq!(stats.pieces_total, 2);
5842        assert_eq!(stats.pieces_have, 0);
5843        assert_eq!(stats.peers_connected, 0);
5844
5845        handle.shutdown().await.unwrap();
5846    }
5847
5848    // ---- M245 F1: atomic TakeResumeIfDirty / MarkResumeDirty ----
5849
5850    /// Build a started `TorrentHandle` over a 2-piece in-memory torrent.
5851    /// Returns the handle, the independently-derived expected piece-hash hex
5852    /// (v1 SHA-1, computed from `meta.info.pieces` BEFORE the meta is consumed —
5853    /// the parity oracle for L3), and the disk-manager join handle (kept alive
5854    /// by the caller — dropping it would abort the disk-manager task). Mirrors
5855    /// `create_from_torrent`'s construction so the tests drive a real actor.
5856    async fn started_test_handle() -> (TorrentHandle, Vec<String>, tokio::task::JoinHandle<()>) {
5857        let data = vec![0xAB; 32768]; // 32 KiB → 2 pieces at 16 KiB
5858        let meta = make_test_torrent(&data, 16384);
5859        let expected_hex: Vec<String> =
5860            meta.info.pieces.chunks_exact(20).map(hex::encode).collect();
5861        let storage = make_storage(&data, 16384);
5862        let config = test_config();
5863
5864        let (atx, amask) = test_alert_channel();
5865        let (dh, dm, dj) = test_register_disk(meta.info_hash, storage).await;
5866        let handle = TorrentHandle::from_torrent(
5867            meta,
5868            irontide_core::TorrentVersion::V1Only,
5869            None,
5870            dh,
5871            dm,
5872            config,
5873            test_dht_rx(),
5874            test_dht_rx(),
5875            None,
5876            None,
5877            crate::slot_tuner::SlotTuner::disabled(4),
5878            atx,
5879            amask,
5880            None,
5881            None,
5882            test_ban_manager(),
5883            test_ip_filter(),
5884            Arc::new(Vec::new()),
5885            None,
5886            None,
5887            Arc::new(crate::transport::NetworkFactory::tokio()),
5888            None,
5889            Arc::new(crate::stats::SessionCounters::new()),
5890        )
5891        .await
5892        .unwrap();
5893        (handle, expected_hex, dj)
5894    }
5895
5896    /// F1 atomicity: a dirty torrent yields resume data exactly ONCE — the take
5897    /// clears `need_save_resume` in the same actor turn, so the immediate second
5898    /// take sees a clean torrent and returns `None`. This is the property the
5899    /// pre-M245 `stats()`→`save_resume_data()`→`clear_save_resume_flag()`
5900    /// three-step could not guarantee (a dirty mark landing between the separate
5901    /// check and clear was lost).
5902    #[tokio::test]
5903    async fn take_resume_if_dirty_is_atomic_capture_and_clear() {
5904        let (handle, _expected_hex, _dj) = started_test_handle().await;
5905
5906        // SetTags is a synchronous, explicit `need_save_resume = true` with a
5907        // reply — a deterministic dirty trigger (no reliance on state-machine
5908        // side effects).
5909        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
5910
5911        let first = handle.take_resume_if_dirty().await.unwrap();
5912        assert!(first.is_some(), "dirty torrent must yield resume data");
5913
5914        let second = handle.take_resume_if_dirty().await.unwrap();
5915        assert!(
5916            second.is_none(),
5917            "flag was cleared atomically in the same take — no second capture"
5918        );
5919
5920        handle.shutdown().await.unwrap();
5921    }
5922
5923    /// F1 retry guarantee (RATIFIED D3-A): after a take has already cleared the
5924    /// flag, a simulated off-actor write failure re-dirties via
5925    /// `mark_resume_dirty`, and the NEXT take re-captures — the resume update is
5926    /// not lost. Without the re-dirty the torrent would stay clean and skip its
5927    /// retry until it next mutated, risking a stale `.resume` on disk.
5928    #[tokio::test]
5929    async fn mark_resume_dirty_restores_capture_after_write_failure() {
5930        let (handle, _expected_hex, _dj) = started_test_handle().await;
5931
5932        handle.set_tags(vec!["m245".to_string()]).await.unwrap();
5933
5934        let captured = handle.take_resume_if_dirty().await.unwrap();
5935        assert!(captured.is_some(), "dirty torrent captured once");
5936
5937        // Clean now — confirm the take cleared it before we re-dirty.
5938        let between = handle.take_resume_if_dirty().await.unwrap();
5939        assert!(between.is_none(), "take cleared the flag");
5940
5941        // Simulate run_resume_save_jobs' write failure path re-arming the flag.
5942        handle.mark_resume_dirty().await.unwrap();
5943
5944        let recaptured = handle.take_resume_if_dirty().await.unwrap();
5945        assert!(
5946            recaptured.is_some(),
5947            "re-dirtied torrent must re-capture — no lost resume update"
5948        );
5949
5950        handle.shutdown().await.unwrap();
5951    }
5952
5953    /// L3 (M245): `get_piece_hashes` returns correctly hex-encoded v1 SHA-1
5954    /// piece hashes. Behaviour-parity guard for moving the `hex::encode` OFF the
5955    /// recv loop (the actor now returns raw bytes for the window; the handle
5956    /// method encodes) — the public `Vec<String>` output must stay byte-identical
5957    /// to encoding `meta.info.pieces` directly. Also pins the windowing
5958    /// semantics: `[offset, offset+limit)` clamped to the hash count.
5959    #[tokio::test]
5960    async fn get_piece_hashes_hex_parity_and_windowing() {
5961        let (handle, expected_hex, _dj) = started_test_handle().await;
5962        assert_eq!(expected_hex.len(), 2, "2-piece test torrent");
5963
5964        // Full range: output equals the independently-computed hex.
5965        let all = handle.get_piece_hashes(0, 1000).await.unwrap();
5966        assert_eq!(
5967            all, expected_hex,
5968            "hex output must match the raw piece hashes"
5969        );
5970
5971        // Window: offset 1, limit 1 → only the second hash.
5972        let windowed = handle.get_piece_hashes(1, 1).await.unwrap();
5973        assert_eq!(windowed, vec![expected_hex[1].clone()]);
5974
5975        // offset 0, limit 1 → only the first.
5976        let first = handle.get_piece_hashes(0, 1).await.unwrap();
5977        assert_eq!(first, vec![expected_hex[0].clone()]);
5978
5979        // Offset past the end → empty (clamped, no panic).
5980        let past = handle.get_piece_hashes(99, 5).await.unwrap();
5981        assert!(past.is_empty(), "offset past end yields empty");
5982
5983        // limit beyond end → clamped to what remains.
5984        let clamped = handle.get_piece_hashes(1, 1000).await.unwrap();
5985        assert_eq!(clamped, vec![expected_hex[1].clone()]);
5986
5987        handle.shutdown().await.unwrap();
5988    }
5989
5990    // ---- Test 2: Create from magnet ----
5991
5992    #[tokio::test]
5993    async fn create_from_magnet() {
5994        let magnet = Magnet {
5995            info_hashes: irontide_core::InfoHashes::v1_only(
5996                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
5997            ),
5998            display_name: Some("test".into()),
5999            trackers: vec![],
6000            peers: vec![],
6001            selected_files: None,
6002        };
6003        let config = test_config();
6004
6005        let (atx, amask) = test_alert_channel();
6006        let (dm, _dj) = test_disk_manager();
6007        let handle = TorrentHandle::from_magnet(
6008            magnet,
6009            dm,
6010            config,
6011            test_dht_rx(),
6012            test_dht_rx(),
6013            None,
6014            None,
6015            crate::slot_tuner::SlotTuner::disabled(4),
6016            atx,
6017            amask,
6018            None,
6019            None,
6020            test_ban_manager(),
6021            test_ip_filter(),
6022            Arc::new(Vec::new()),
6023            None,
6024            None,
6025            Arc::new(crate::transport::NetworkFactory::tokio()),
6026            None, // M96: hash_pool
6027            Arc::new(crate::stats::SessionCounters::new()),
6028        )
6029        .await
6030        .unwrap();
6031
6032        let stats = handle.stats().await.unwrap();
6033        assert_eq!(stats.state, TorrentState::FetchingMetadata);
6034        assert_eq!(stats.pieces_total, 0);
6035
6036        handle.shutdown().await.unwrap();
6037    }
6038
6039    // ---- Test 3: Add peers ----
6040
6041    #[tokio::test]
6042    async fn add_peers_increases_available() {
6043        let data = vec![0xAB; 32768];
6044        let meta = make_test_torrent(&data, 16384);
6045        let storage = make_storage(&data, 16384);
6046        let config = test_config();
6047
6048        let (atx, amask) = test_alert_channel();
6049        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6050        let handle = TorrentHandle::from_torrent(
6051            meta,
6052            irontide_core::TorrentVersion::V1Only,
6053            None,
6054            dh,
6055            dm,
6056            config,
6057            test_dht_rx(),
6058            test_dht_rx(),
6059            None,
6060            None,
6061            crate::slot_tuner::SlotTuner::disabled(4),
6062            atx,
6063            amask,
6064            None,
6065            None,
6066            test_ban_manager(),
6067            test_ip_filter(),
6068            Arc::new(Vec::new()),
6069            None,
6070            None,
6071            Arc::new(crate::transport::NetworkFactory::tokio()),
6072            None, // M96: hash_pool
6073            Arc::new(crate::stats::SessionCounters::new()),
6074        )
6075        .await
6076        .unwrap();
6077
6078        // Bind listeners so the connect attempts succeed and peers stay in connected state
6079        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6080        let addr1 = listener1.local_addr().unwrap();
6081        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
6082        let addr2 = listener2.local_addr().unwrap();
6083
6084        handle
6085            .add_peers(vec![addr1, addr2], PeerSource::Tracker)
6086            .await
6087            .unwrap();
6088
6089        // Small delay for the actor to process
6090        tokio::time::sleep(Duration::from_millis(100)).await;
6091
6092        let stats = handle.stats().await.unwrap();
6093        // Peers may be available or already connecting (try_connect_peers fires immediately)
6094        assert!(
6095            stats.peers_available + stats.peers_connected >= 2,
6096            "expected at least 2 peers known, got available={}, connected={}",
6097            stats.peers_available,
6098            stats.peers_connected
6099        );
6100
6101        handle.shutdown().await.unwrap();
6102    }
6103
6104    // ---- Test 4: Stats reporting ----
6105
6106    #[tokio::test]
6107    async fn stats_reporting() {
6108        let data = vec![0xAB; 65536]; // 64 KiB
6109        let meta = make_test_torrent(&data, 16384); // 4 pieces
6110        let storage = make_storage(&data, 16384);
6111        let config = test_config();
6112
6113        let (atx, amask) = test_alert_channel();
6114        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6115        let handle = TorrentHandle::from_torrent(
6116            meta,
6117            irontide_core::TorrentVersion::V1Only,
6118            None,
6119            dh,
6120            dm,
6121            config,
6122            test_dht_rx(),
6123            test_dht_rx(),
6124            None,
6125            None,
6126            crate::slot_tuner::SlotTuner::disabled(4),
6127            atx,
6128            amask,
6129            None,
6130            None,
6131            test_ban_manager(),
6132            test_ip_filter(),
6133            Arc::new(Vec::new()),
6134            None,
6135            None,
6136            Arc::new(crate::transport::NetworkFactory::tokio()),
6137            None, // M96: hash_pool
6138            Arc::new(crate::stats::SessionCounters::new()),
6139        )
6140        .await
6141        .unwrap();
6142
6143        let stats = handle.stats().await.unwrap();
6144        assert_eq!(stats.state, TorrentState::Downloading);
6145        assert_eq!(stats.downloaded, 0);
6146        assert_eq!(stats.uploaded, 0);
6147        assert_eq!(stats.pieces_have, 0);
6148        assert_eq!(stats.pieces_total, 4);
6149        assert_eq!(stats.peers_connected, 0);
6150        assert_eq!(stats.peers_available, 0);
6151
6152        handle.shutdown().await.unwrap();
6153    }
6154
6155    // ---- Test 5: Private torrent disables DHT/PEX ----
6156
6157    #[tokio::test]
6158    async fn private_torrent_disables_dht_pex() {
6159        // Build a private torrent by embedding private=1 in the info dict
6160        use serde::Serialize;
6161
6162        #[derive(Serialize)]
6163        struct Info<'a> {
6164            length: u64,
6165            name: &'a str,
6166            #[serde(rename = "piece length")]
6167            piece_length: u64,
6168            #[serde(with = "serde_bytes")]
6169            pieces: &'a [u8],
6170            private: i64,
6171        }
6172
6173        #[derive(Serialize)]
6174        struct Torrent<'a> {
6175            info: Info<'a>,
6176        }
6177
6178        let data = vec![0xAB; 16384];
6179        let hash = irontide_core::sha1(&data);
6180        let mut pieces = Vec::new();
6181        pieces.extend_from_slice(hash.as_bytes());
6182
6183        let t = Torrent {
6184            info: Info {
6185                length: data.len() as u64,
6186                name: "private_test",
6187                piece_length: 16384,
6188                pieces: &pieces,
6189                private: 1,
6190            },
6191        };
6192
6193        let bytes = irontide_bencode::to_bytes(&t).unwrap();
6194        let meta = torrent_from_bytes(&bytes).unwrap();
6195        assert_eq!(meta.info.private, Some(1));
6196
6197        let storage = make_storage(&data, 16384);
6198        let mut config = test_config();
6199        config.enable_dht = true;
6200        config.enable_pex = true;
6201
6202        // The from_torrent constructor should disable DHT and PEX
6203        let (atx, amask) = test_alert_channel();
6204        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6205        let handle = TorrentHandle::from_torrent(
6206            meta,
6207            irontide_core::TorrentVersion::V1Only,
6208            None,
6209            dh,
6210            dm,
6211            config,
6212            test_dht_rx(),
6213            test_dht_rx(),
6214            None,
6215            None,
6216            crate::slot_tuner::SlotTuner::disabled(4),
6217            atx,
6218            amask,
6219            None,
6220            None,
6221            test_ban_manager(),
6222            test_ip_filter(),
6223            Arc::new(Vec::new()),
6224            None,
6225            None,
6226            Arc::new(crate::transport::NetworkFactory::tokio()),
6227            None, // M96: hash_pool
6228            Arc::new(crate::stats::SessionCounters::new()),
6229        )
6230        .await
6231        .unwrap();
6232
6233        // We can't directly inspect the actor's config, but we can verify
6234        // the torrent was created successfully. The real test is that PEX peers
6235        // would be ignored and DHT not used. For now verify the handle works.
6236        let stats = handle.stats().await.unwrap();
6237        assert_eq!(stats.state, TorrentState::Downloading);
6238
6239        handle.shutdown().await.unwrap();
6240    }
6241
6242    // ---- Test 6: Shutdown cleanup ----
6243
6244    #[tokio::test]
6245    async fn shutdown_cleanup() {
6246        let data = vec![0xAB; 16384];
6247        let meta = make_test_torrent(&data, 16384);
6248        let storage = make_storage(&data, 16384);
6249        let config = test_config();
6250
6251        let (atx, amask) = test_alert_channel();
6252        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6253        let handle = TorrentHandle::from_torrent(
6254            meta,
6255            irontide_core::TorrentVersion::V1Only,
6256            None,
6257            dh,
6258            dm,
6259            config,
6260            test_dht_rx(),
6261            test_dht_rx(),
6262            None,
6263            None,
6264            crate::slot_tuner::SlotTuner::disabled(4),
6265            atx,
6266            amask,
6267            None,
6268            None,
6269            test_ban_manager(),
6270            test_ip_filter(),
6271            Arc::new(Vec::new()),
6272            None,
6273            None,
6274            Arc::new(crate::transport::NetworkFactory::tokio()),
6275            None, // M96: hash_pool
6276            Arc::new(crate::stats::SessionCounters::new()),
6277        )
6278        .await
6279        .unwrap();
6280
6281        handle.shutdown().await.unwrap();
6282
6283        // After shutdown, stats should fail (channel closed)
6284        tokio::time::sleep(Duration::from_millis(50)).await;
6285        let result = handle.stats().await;
6286        assert!(result.is_err());
6287    }
6288
6289    // ---- Test 7: Duplicate add_peers ignored ----
6290
6291    #[tokio::test]
6292    async fn duplicate_peers_ignored() {
6293        let data = vec![0xAB; 16384];
6294        let meta = make_test_torrent(&data, 16384);
6295        let storage = make_storage(&data, 16384);
6296        let config = test_config();
6297
6298        let (atx, amask) = test_alert_channel();
6299        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6300        let handle = TorrentHandle::from_torrent(
6301            meta,
6302            irontide_core::TorrentVersion::V1Only,
6303            None,
6304            dh,
6305            dm,
6306            config,
6307            test_dht_rx(),
6308            test_dht_rx(),
6309            None,
6310            None,
6311            crate::slot_tuner::SlotTuner::disabled(4),
6312            atx,
6313            amask,
6314            None,
6315            None,
6316            test_ban_manager(),
6317            test_ip_filter(),
6318            Arc::new(Vec::new()),
6319            None,
6320            None,
6321            Arc::new(crate::transport::NetworkFactory::tokio()),
6322            None, // M96: hash_pool
6323            Arc::new(crate::stats::SessionCounters::new()),
6324        )
6325        .await
6326        .unwrap();
6327
6328        // Bind a listener so the connection succeeds and the peer stays connected
6329        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6330        let addr = listener.local_addr().unwrap();
6331        handle
6332            .add_peers(vec![addr, addr, addr], PeerSource::Tracker)
6333            .await
6334            .unwrap();
6335
6336        tokio::time::sleep(Duration::from_millis(100)).await;
6337        let stats = handle.stats().await.unwrap();
6338        // Only one unique peer should be known (available or connecting)
6339        assert!(
6340            stats.peers_available + stats.peers_connected <= 1,
6341            "expected at most 1 unique peer, got available={}, connected={}",
6342            stats.peers_available,
6343            stats.peers_connected
6344        );
6345
6346        handle.shutdown().await.unwrap();
6347    }
6348
6349    // ---- Test 8: Multiple handles (Clone) share same actor ----
6350
6351    #[tokio::test]
6352    async fn cloned_handle_shares_actor() {
6353        let data = vec![0xAB; 16384];
6354        let meta = make_test_torrent(&data, 16384);
6355        let storage = make_storage(&data, 16384);
6356        let config = test_config();
6357
6358        let (atx, amask) = test_alert_channel();
6359        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6360        let handle = TorrentHandle::from_torrent(
6361            meta,
6362            irontide_core::TorrentVersion::V1Only,
6363            None,
6364            dh,
6365            dm,
6366            config,
6367            test_dht_rx(),
6368            test_dht_rx(),
6369            None,
6370            None,
6371            crate::slot_tuner::SlotTuner::disabled(4),
6372            atx,
6373            amask,
6374            None,
6375            None,
6376            test_ban_manager(),
6377            test_ip_filter(),
6378            Arc::new(Vec::new()),
6379            None,
6380            None,
6381            Arc::new(crate::transport::NetworkFactory::tokio()),
6382            None, // M96: hash_pool
6383            Arc::new(crate::stats::SessionCounters::new()),
6384        )
6385        .await
6386        .unwrap();
6387        let handle2 = handle.clone();
6388
6389        // Bind a listener so the connection succeeds and the peer stays connected
6390        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6391        let peer_addr = listener.local_addr().unwrap();
6392
6393        // Add peers through one handle
6394        handle
6395            .add_peers(vec![peer_addr], PeerSource::Tracker)
6396            .await
6397            .unwrap();
6398
6399        tokio::time::sleep(Duration::from_millis(100)).await;
6400
6401        // Read stats through the other — peer may be available or connecting
6402        let stats = handle2.stats().await.unwrap();
6403        assert!(
6404            stats.peers_available + stats.peers_connected >= 1,
6405            "expected at least 1 peer known, got available={}, connected={}",
6406            stats.peers_available,
6407            stats.peers_connected
6408        );
6409
6410        handle.shutdown().await.unwrap();
6411    }
6412
6413    // ---- Test 9: Peer connection and disconnect via listener ----
6414
6415    #[tokio::test]
6416    async fn peer_connect_and_disconnect_via_listener() {
6417        let data = vec![0xAB; 16384];
6418        let meta = make_test_torrent(&data, 16384);
6419        let info_hash = meta.info_hash;
6420        let storage = make_storage(&data, 16384);
6421
6422        // Bind a listener on a specific port so we can connect to it
6423        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6424        let listen_addr = listener.local_addr().unwrap();
6425
6426        let config = TorrentConfig {
6427            listen_port: listen_addr.port(),
6428            ..test_config()
6429        };
6430
6431        // Drop the pre-bound listener before from_torrent binds
6432        drop(listener);
6433
6434        let (atx, amask) = test_alert_channel();
6435        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6436        let handle = TorrentHandle::from_torrent(
6437            meta,
6438            irontide_core::TorrentVersion::V1Only,
6439            None,
6440            dh,
6441            dm,
6442            config,
6443            test_dht_rx(),
6444            test_dht_rx(),
6445            None,
6446            None,
6447            crate::slot_tuner::SlotTuner::disabled(4),
6448            atx,
6449            amask,
6450            None,
6451            None,
6452            test_ban_manager(),
6453            test_ip_filter(),
6454            Arc::new(Vec::new()),
6455            None,
6456            None,
6457            Arc::new(crate::transport::NetworkFactory::tokio()),
6458            None, // M96: hash_pool
6459            Arc::new(crate::stats::SessionCounters::new()),
6460        )
6461        .await
6462        .unwrap();
6463
6464        // Give the actor time to start
6465        tokio::time::sleep(Duration::from_millis(50)).await;
6466
6467        // Connect a mock peer
6468        let mut stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6469
6470        // Perform handshake
6471        let remote_id = Id20::from_hex("1111111111111111111111111111111111111111").unwrap();
6472        let remote_hs = Handshake::new(info_hash, remote_id);
6473        stream.write_all(&remote_hs.to_bytes()).await.unwrap();
6474        stream.flush().await.unwrap();
6475
6476        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6477        stream.read_exact(&mut hs_buf).await.unwrap();
6478        let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
6479        assert_eq!(their_hs.info_hash, info_hash);
6480
6481        // Give time for peer to be registered
6482        tokio::time::sleep(Duration::from_millis(100)).await;
6483
6484        let stats = handle.stats().await.unwrap();
6485        assert_eq!(stats.peers_connected, 1);
6486
6487        // Drop the connection
6488        drop(stream);
6489
6490        // Wait for disconnect event
6491        tokio::time::sleep(Duration::from_millis(200)).await;
6492
6493        let stats = handle.stats().await.unwrap();
6494        assert_eq!(stats.peers_connected, 0);
6495
6496        handle.shutdown().await.unwrap();
6497    }
6498
6499    // ---- Test 10: Piece download and verification via injected events ----
6500    //
6501    // We test the full flow: connect a mock peer that sends bitfield, unchoke,
6502    // then responds to requests with correct piece data.
6503
6504    #[tokio::test]
6505    async fn piece_download_and_verify() {
6506        // Create a 1-piece torrent with 16384 bytes (exactly one chunk)
6507        let data = vec![0xCDu8; 16384];
6508        let meta = make_test_torrent(&data, 16384);
6509        let info_hash = meta.info_hash;
6510        let storage = make_storage(&data, 16384);
6511
6512        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6513        let listen_addr = listener.local_addr().unwrap();
6514        drop(listener);
6515
6516        let config = TorrentConfig {
6517            listen_port: listen_addr.port(),
6518            ..test_config()
6519        };
6520
6521        let (atx, amask) = test_alert_channel();
6522        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6523        let handle = TorrentHandle::from_torrent(
6524            meta,
6525            irontide_core::TorrentVersion::V1Only,
6526            None,
6527            dh,
6528            dm,
6529            config,
6530            test_dht_rx(),
6531            test_dht_rx(),
6532            None,
6533            None,
6534            crate::slot_tuner::SlotTuner::disabled(4),
6535            atx,
6536            amask,
6537            None,
6538            None,
6539            test_ban_manager(),
6540            test_ip_filter(),
6541            Arc::new(Vec::new()),
6542            None,
6543            None,
6544            Arc::new(crate::transport::NetworkFactory::tokio()),
6545            None, // M96: hash_pool
6546            Arc::new(crate::stats::SessionCounters::new()),
6547        )
6548        .await
6549        .unwrap();
6550
6551        tokio::time::sleep(Duration::from_millis(50)).await;
6552
6553        // Connect mock peer
6554        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6555        let remote_id = Id20::from_hex("2222222222222222222222222222222222222222").unwrap();
6556
6557        // Run mock seeder in a task
6558        let mock_data = data.clone();
6559        let mock_task = tokio::spawn(async move {
6560            let (reader, writer) = tokio::io::split(stream);
6561            let mut reader = reader;
6562            let mut writer = writer;
6563
6564            // Handshake
6565            let hs = Handshake::new(info_hash, remote_id);
6566            writer.write_all(&hs.to_bytes()).await.unwrap();
6567            writer.flush().await.unwrap();
6568
6569            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6570            reader.read_exact(&mut hs_buf).await.unwrap();
6571
6572            // Switch to framed
6573            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6574            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6575
6576            // Read ext handshake from the torrent actor's peer
6577            let _msg = framed_read.next().await;
6578
6579            // Send ext handshake back
6580            let ext_hs = ExtHandshake::new();
6581            let payload = ext_hs.to_bytes().unwrap();
6582            framed_write
6583                .send(Message::Extended { ext_id: 0, payload })
6584                .await
6585                .unwrap();
6586
6587            // Send bitfield (all pieces = piece 0 set)
6588            let mut bf = Bitfield::new(1);
6589            bf.set(0);
6590            framed_write
6591                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6592                .await
6593                .unwrap();
6594
6595            // Send Unchoke
6596            framed_write.send(Message::Unchoke).await.unwrap();
6597
6598            // Wait for requests and respond with piece data
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 start = begin as usize;
6607                    let end = start + length as usize;
6608                    let piece_data = &mock_data[start..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        // Wait for the download to complete
6623        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6624        loop {
6625            tokio::time::sleep(Duration::from_millis(100)).await;
6626            let stats = handle.stats().await.unwrap();
6627            if stats.state == TorrentState::Seeding {
6628                assert_eq!(stats.pieces_have, 1);
6629                assert_eq!(stats.pieces_total, 1);
6630                break;
6631            }
6632            if tokio::time::Instant::now() > deadline {
6633                let stats = handle.stats().await.unwrap();
6634                panic!(
6635                    "download did not complete within 5s, state={:?}, have={}/{}",
6636                    stats.state, stats.pieces_have, stats.pieces_total
6637                );
6638            }
6639        }
6640
6641        handle.shutdown().await.unwrap();
6642        mock_task.abort();
6643    }
6644
6645    // ---- Test 11: Failed piece verification re-requests ----
6646
6647    #[tokio::test]
6648    async fn failed_piece_verification() {
6649        // Create a 1-piece torrent
6650        let data = vec![0xEEu8; 16384];
6651        let meta = make_test_torrent(&data, 16384);
6652        let info_hash = meta.info_hash;
6653        let storage = make_storage(&data, 16384);
6654
6655        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6656        let listen_addr = listener.local_addr().unwrap();
6657        drop(listener);
6658
6659        let config = TorrentConfig {
6660            listen_port: listen_addr.port(),
6661            ..test_config()
6662        };
6663
6664        let (atx, amask) = test_alert_channel();
6665        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6666        let handle = TorrentHandle::from_torrent(
6667            meta,
6668            irontide_core::TorrentVersion::V1Only,
6669            None,
6670            dh,
6671            dm,
6672            config,
6673            test_dht_rx(),
6674            test_dht_rx(),
6675            None,
6676            None,
6677            crate::slot_tuner::SlotTuner::disabled(4),
6678            atx,
6679            amask,
6680            None,
6681            None,
6682            test_ban_manager(),
6683            test_ip_filter(),
6684            Arc::new(Vec::new()),
6685            None,
6686            None,
6687            Arc::new(crate::transport::NetworkFactory::tokio()),
6688            None, // M96: hash_pool
6689            Arc::new(crate::stats::SessionCounters::new()),
6690        )
6691        .await
6692        .unwrap();
6693
6694        tokio::time::sleep(Duration::from_millis(50)).await;
6695
6696        // Connect mock peer that first sends bad data, then correct data
6697        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6698        let remote_id = Id20::from_hex("3333333333333333333333333333333333333333").unwrap();
6699
6700        let correct_data = data.clone();
6701        let mock_task = tokio::spawn(async move {
6702            let (reader, writer) = tokio::io::split(stream);
6703
6704            // Handshake
6705            let mut writer = writer;
6706            let mut reader = reader;
6707            let hs = Handshake::new(info_hash, remote_id);
6708            writer.write_all(&hs.to_bytes()).await.unwrap();
6709            writer.flush().await.unwrap();
6710
6711            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6712            reader.read_exact(&mut hs_buf).await.unwrap();
6713
6714            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6715            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6716
6717            // Read ext handshake
6718            let _msg = framed_read.next().await;
6719
6720            // Send ext handshake
6721            let ext_hs = ExtHandshake::new();
6722            let payload = ext_hs.to_bytes().unwrap();
6723            framed_write
6724                .send(Message::Extended { ext_id: 0, payload })
6725                .await
6726                .unwrap();
6727
6728            // Bitfield: have piece 0
6729            let mut bf = Bitfield::new(1);
6730            bf.set(0);
6731            framed_write
6732                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6733                .await
6734                .unwrap();
6735
6736            // Unchoke
6737            framed_write.send(Message::Unchoke).await.unwrap();
6738
6739            let mut request_count = 0u32;
6740            while let Some(Ok(msg)) = framed_read.next().await {
6741                if let Message::Request {
6742                    index,
6743                    begin,
6744                    length,
6745                } = msg
6746                {
6747                    request_count += 1;
6748                    let piece_data = if request_count <= 1 {
6749                        // First request: send bad data
6750                        vec![0xFF; length as usize]
6751                    } else {
6752                        // Subsequent: send correct data
6753                        let start = begin as usize;
6754                        let end = start + length as usize;
6755                        correct_data[start..end].to_vec()
6756                    };
6757                    framed_write
6758                        .send(Message::Piece {
6759                            index,
6760                            begin,
6761                            data_0: Bytes::from(piece_data),
6762                            data_1: Bytes::new(),
6763                        })
6764                        .await
6765                        .unwrap();
6766                }
6767            }
6768        });
6769
6770        // Wait for completion (should eventually succeed after retry)
6771        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6772        loop {
6773            tokio::time::sleep(Duration::from_millis(100)).await;
6774            let stats = handle.stats().await.unwrap();
6775            if stats.state == TorrentState::Seeding {
6776                assert_eq!(stats.pieces_have, 1);
6777                break;
6778            }
6779            if tokio::time::Instant::now() > deadline {
6780                let stats = handle.stats().await.unwrap();
6781                panic!(
6782                    "download did not complete after retry within 5s, state={:?}, have={}",
6783                    stats.state, stats.pieces_have,
6784                );
6785            }
6786        }
6787
6788        handle.shutdown().await.unwrap();
6789        mock_task.abort();
6790    }
6791
6792    // ---- Test 12: Complete state transitions after all pieces ----
6793
6794    #[tokio::test]
6795    async fn complete_transitions_state() {
6796        // 2-piece torrent, each 16384 bytes (one chunk each)
6797        let data = vec![0xBBu8; 32768];
6798        let meta = make_test_torrent(&data, 16384);
6799        let info_hash = meta.info_hash;
6800        let storage = make_storage(&data, 16384);
6801
6802        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6803        let listen_addr = listener.local_addr().unwrap();
6804        drop(listener);
6805
6806        let config = TorrentConfig {
6807            listen_port: listen_addr.port(),
6808            ..test_config()
6809        };
6810
6811        let (atx, amask) = test_alert_channel();
6812        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6813        let handle = TorrentHandle::from_torrent(
6814            meta,
6815            irontide_core::TorrentVersion::V1Only,
6816            None,
6817            dh,
6818            dm,
6819            config,
6820            test_dht_rx(),
6821            test_dht_rx(),
6822            None,
6823            None,
6824            crate::slot_tuner::SlotTuner::disabled(4),
6825            atx,
6826            amask,
6827            None,
6828            None,
6829            test_ban_manager(),
6830            test_ip_filter(),
6831            Arc::new(Vec::new()),
6832            None,
6833            None,
6834            Arc::new(crate::transport::NetworkFactory::tokio()),
6835            None, // M96: hash_pool
6836            Arc::new(crate::stats::SessionCounters::new()),
6837        )
6838        .await
6839        .unwrap();
6840
6841        tokio::time::sleep(Duration::from_millis(50)).await;
6842
6843        // Mock seeder with all 2 pieces
6844        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6845        let remote_id = Id20::from_hex("4444444444444444444444444444444444444444").unwrap();
6846
6847        let mock_data = data.clone();
6848        let mock_task = tokio::spawn(async move {
6849            let (reader, writer) = tokio::io::split(stream);
6850            let mut writer = writer;
6851            let mut reader = reader;
6852
6853            let hs = Handshake::new(info_hash, remote_id);
6854            writer.write_all(&hs.to_bytes()).await.unwrap();
6855            writer.flush().await.unwrap();
6856
6857            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6858            reader.read_exact(&mut hs_buf).await.unwrap();
6859
6860            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6861            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6862
6863            // Read ext handshake
6864            let _msg = framed_read.next().await;
6865
6866            // Send ext handshake
6867            let ext_hs = ExtHandshake::new();
6868            let payload = ext_hs.to_bytes().unwrap();
6869            framed_write
6870                .send(Message::Extended { ext_id: 0, payload })
6871                .await
6872                .unwrap();
6873
6874            // Bitfield: have both pieces
6875            let mut bf = Bitfield::new(2);
6876            bf.set(0);
6877            bf.set(1);
6878            framed_write
6879                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6880                .await
6881                .unwrap();
6882
6883            framed_write.send(Message::Unchoke).await.unwrap();
6884
6885            while let Some(Ok(msg)) = framed_read.next().await {
6886                if let Message::Request {
6887                    index,
6888                    begin,
6889                    length,
6890                } = msg
6891                {
6892                    let abs_start = (index as usize * 16384) + begin as usize;
6893                    let abs_end = abs_start + length as usize;
6894                    let piece_data = &mock_data[abs_start..abs_end];
6895                    framed_write
6896                        .send(Message::Piece {
6897                            index,
6898                            begin,
6899                            data_0: Bytes::copy_from_slice(piece_data),
6900                            data_1: Bytes::new(),
6901                        })
6902                        .await
6903                        .unwrap();
6904                }
6905            }
6906        });
6907
6908        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6909        loop {
6910            tokio::time::sleep(Duration::from_millis(100)).await;
6911            let stats = handle.stats().await.unwrap();
6912            if stats.state == TorrentState::Seeding {
6913                assert_eq!(stats.pieces_have, 2);
6914                assert_eq!(stats.pieces_total, 2);
6915                break;
6916            }
6917            if tokio::time::Instant::now() > deadline {
6918                let stats = handle.stats().await.unwrap();
6919                panic!(
6920                    "expected Complete, got {:?}, have={}/{}",
6921                    stats.state, stats.pieces_have, stats.pieces_total
6922                );
6923            }
6924        }
6925
6926        handle.shutdown().await.unwrap();
6927        mock_task.abort();
6928    }
6929
6930    // ---- Test 13: Multiple pieces with multi-chunk pieces ----
6931
6932    #[tokio::test]
6933    async fn multi_chunk_piece_download() {
6934        // 1 piece of 32768 bytes = 2 chunks of 16384 each
6935        let data = vec![0xAAu8; 32768];
6936        let meta = make_test_torrent(&data, 32768);
6937        let info_hash = meta.info_hash;
6938        let storage = make_storage(&data, 32768);
6939
6940        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6941        let listen_addr = listener.local_addr().unwrap();
6942        drop(listener);
6943
6944        let config = TorrentConfig {
6945            listen_port: listen_addr.port(),
6946            ..test_config()
6947        };
6948
6949        let (atx, amask) = test_alert_channel();
6950        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6951        let handle = TorrentHandle::from_torrent(
6952            meta,
6953            irontide_core::TorrentVersion::V1Only,
6954            None,
6955            dh,
6956            dm,
6957            config,
6958            test_dht_rx(),
6959            test_dht_rx(),
6960            None,
6961            None,
6962            crate::slot_tuner::SlotTuner::disabled(4),
6963            atx,
6964            amask,
6965            None,
6966            None,
6967            test_ban_manager(),
6968            test_ip_filter(),
6969            Arc::new(Vec::new()),
6970            None,
6971            None,
6972            Arc::new(crate::transport::NetworkFactory::tokio()),
6973            None, // M96: hash_pool
6974            Arc::new(crate::stats::SessionCounters::new()),
6975        )
6976        .await
6977        .unwrap();
6978
6979        tokio::time::sleep(Duration::from_millis(50)).await;
6980
6981        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6982        let remote_id = Id20::from_hex("5555555555555555555555555555555555555555").unwrap();
6983
6984        let mock_data = data.clone();
6985        let mock_task = tokio::spawn(async move {
6986            let (reader, writer) = tokio::io::split(stream);
6987            let mut writer = writer;
6988            let mut reader = reader;
6989
6990            let hs = Handshake::new(info_hash, remote_id);
6991            writer.write_all(&hs.to_bytes()).await.unwrap();
6992            writer.flush().await.unwrap();
6993
6994            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6995            reader.read_exact(&mut hs_buf).await.unwrap();
6996
6997            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6998            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6999
7000            let _msg = framed_read.next().await;
7001
7002            let ext_hs = ExtHandshake::new();
7003            let payload = ext_hs.to_bytes().unwrap();
7004            framed_write
7005                .send(Message::Extended { ext_id: 0, payload })
7006                .await
7007                .unwrap();
7008
7009            let mut bf = Bitfield::new(1);
7010            bf.set(0);
7011            framed_write
7012                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7013                .await
7014                .unwrap();
7015
7016            framed_write.send(Message::Unchoke).await.unwrap();
7017
7018            while let Some(Ok(msg)) = framed_read.next().await {
7019                if let Message::Request {
7020                    index: _,
7021                    begin,
7022                    length,
7023                } = msg
7024                {
7025                    let start = begin as usize;
7026                    let end = start + length as usize;
7027                    framed_write
7028                        .send(Message::Piece {
7029                            index: 0,
7030                            begin,
7031                            data_0: Bytes::copy_from_slice(&mock_data[start..end]),
7032                            data_1: Bytes::new(),
7033                        })
7034                        .await
7035                        .unwrap();
7036                }
7037            }
7038        });
7039
7040        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7041        loop {
7042            tokio::time::sleep(Duration::from_millis(100)).await;
7043            let stats = handle.stats().await.unwrap();
7044            if stats.state == TorrentState::Seeding {
7045                assert_eq!(stats.pieces_have, 1);
7046                break;
7047            }
7048            assert!(
7049                tokio::time::Instant::now() <= deadline,
7050                "multi-chunk download did not complete within 5s"
7051            );
7052        }
7053
7054        handle.shutdown().await.unwrap();
7055        mock_task.abort();
7056    }
7057
7058    // ---- Test 14: Seeder/Leecher integration with two actors ----
7059
7060    #[tokio::test]
7061    async fn seeder_leecher_integration() {
7062        // Seeder has all data, leecher has none. Connect them via TCP.
7063        let data = vec![0xDDu8; 32768]; // 32 KiB, 2 pieces of 16384
7064        let piece_length = 16384u64;
7065        let meta = make_test_torrent(&data, piece_length);
7066        let info_hash = meta.info_hash;
7067
7068        // Seeder: storage pre-filled
7069        let seeder_storage = make_seeded_storage(&data, piece_length);
7070
7071        // For the seeder, we need a from_torrent variant that starts in Complete state
7072        // but still serves pieces. Since our actor starts in Downloading, the seeder
7073        // will just be a mock that accepts and serves.
7074
7075        // Use a mock seeder approach instead (manual protocol handling):
7076        let seeder_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7077        let seeder_addr = seeder_listener.local_addr().unwrap();
7078
7079        let seeder_task = tokio::spawn(async move {
7080            let (stream, _addr) = seeder_listener.accept().await.unwrap();
7081            let (reader, writer) = tokio::io::split(stream);
7082            let mut writer = writer;
7083            let mut reader = reader;
7084
7085            // Handshake
7086            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7087            reader.read_exact(&mut hs_buf).await.unwrap();
7088            let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
7089            assert_eq!(their_hs.info_hash, info_hash);
7090
7091            let hs = Handshake::new(info_hash, PeerId::generate().0);
7092            writer.write_all(&hs.to_bytes()).await.unwrap();
7093            writer.flush().await.unwrap();
7094
7095            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7096            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7097
7098            // Read ext handshake
7099            let _msg = framed_read.next().await;
7100
7101            // Send ext handshake
7102            let ext_hs = ExtHandshake::new();
7103            let payload = ext_hs.to_bytes().unwrap();
7104            framed_write
7105                .send(Message::Extended { ext_id: 0, payload })
7106                .await
7107                .unwrap();
7108
7109            // Send bitfield (all pieces)
7110            let mut bf = Bitfield::new(2);
7111            bf.set(0);
7112            bf.set(1);
7113            framed_write
7114                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7115                .await
7116                .unwrap();
7117
7118            // Unchoke
7119            framed_write.send(Message::Unchoke).await.unwrap();
7120
7121            // Serve requests
7122            while let Some(Ok(msg)) = framed_read.next().await {
7123                if let Message::Request {
7124                    index,
7125                    begin,
7126                    length,
7127                } = msg
7128                {
7129                    let piece_data = seeder_storage.read_chunk(index, begin, length).unwrap();
7130                    framed_write
7131                        .send(Message::Piece {
7132                            index,
7133                            begin,
7134                            data_0: Bytes::from(piece_data),
7135                            data_1: Bytes::new(),
7136                        })
7137                        .await
7138                        .unwrap();
7139                }
7140            }
7141        });
7142
7143        // Leecher: empty storage
7144        let leecher_storage = make_storage(&data, piece_length);
7145        let leecher_meta = make_test_torrent(&data, piece_length);
7146
7147        let leecher_config = test_config();
7148        let (latx, lamask) = test_alert_channel();
7149        let (ldh, ldm, _ldj) = test_register_disk(leecher_meta.info_hash, leecher_storage).await;
7150        let leecher = TorrentHandle::from_torrent(
7151            leecher_meta,
7152            irontide_core::TorrentVersion::V1Only,
7153            None,
7154            ldh,
7155            ldm,
7156            leecher_config,
7157            test_dht_rx(),
7158            test_dht_rx(),
7159            None,
7160            None,
7161            crate::slot_tuner::SlotTuner::disabled(4),
7162            latx,
7163            lamask,
7164            None,
7165            None,
7166            test_ban_manager(),
7167            test_ip_filter(),
7168            Arc::new(Vec::new()),
7169            None,
7170            None,
7171            Arc::new(crate::transport::NetworkFactory::tokio()),
7172            None, // M96: hash_pool
7173            Arc::new(crate::stats::SessionCounters::new()),
7174        )
7175        .await
7176        .unwrap();
7177
7178        // Add seeder as a peer
7179        leecher
7180            .add_peers(vec![seeder_addr], PeerSource::Tracker)
7181            .await
7182            .unwrap();
7183
7184        // Give the connect interval time to fire (it ticks every 5s).
7185        // The actor's try_connect_peers runs on the timer, and also immediately
7186        // when peers are added via AddPeers command. Wait up to 10 seconds.
7187        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
7188        loop {
7189            tokio::time::sleep(Duration::from_millis(200)).await;
7190            let stats = leecher.stats().await.unwrap();
7191            if stats.state == TorrentState::Seeding {
7192                assert_eq!(stats.pieces_have, 2);
7193                assert_eq!(stats.pieces_total, 2);
7194                break;
7195            }
7196            if tokio::time::Instant::now() > deadline {
7197                let stats = leecher.stats().await.unwrap();
7198                panic!(
7199                    "seeder/leecher: leecher did not complete, state={:?}, have={}/{}, connected={}, available={}",
7200                    stats.state,
7201                    stats.pieces_have,
7202                    stats.pieces_total,
7203                    stats.peers_connected,
7204                    stats.peers_available,
7205                );
7206            }
7207        }
7208
7209        leecher.shutdown().await.unwrap();
7210        seeder_task.abort();
7211    }
7212
7213    // ---- Test 15: Magnet stats ----
7214
7215    #[tokio::test]
7216    async fn magnet_initial_stats() {
7217        let magnet = Magnet {
7218            info_hashes: irontide_core::InfoHashes::v1_only(
7219                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
7220            ),
7221            display_name: Some("magnet test".into()),
7222            trackers: vec![],
7223            peers: vec![],
7224            selected_files: None,
7225        };
7226
7227        let (atx, amask) = test_alert_channel();
7228        let (dm, _dj) = test_disk_manager();
7229        let handle = TorrentHandle::from_magnet(
7230            magnet,
7231            dm,
7232            test_config(),
7233            test_dht_rx(),
7234            test_dht_rx(),
7235            None,
7236            None,
7237            crate::slot_tuner::SlotTuner::disabled(4),
7238            atx,
7239            amask,
7240            None,
7241            None,
7242            test_ban_manager(),
7243            test_ip_filter(),
7244            Arc::new(Vec::new()),
7245            None,
7246            None,
7247            Arc::new(crate::transport::NetworkFactory::tokio()),
7248            None, // M96: hash_pool
7249            Arc::new(crate::stats::SessionCounters::new()),
7250        )
7251        .await
7252        .unwrap();
7253
7254        let stats = handle.stats().await.unwrap();
7255        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7256        assert_eq!(stats.pieces_total, 0);
7257        assert_eq!(stats.pieces_have, 0);
7258        assert_eq!(stats.downloaded, 0);
7259        assert_eq!(stats.uploaded, 0);
7260        assert_eq!(stats.peers_connected, 0);
7261        assert_eq!(stats.peers_available, 0);
7262
7263        handle.shutdown().await.unwrap();
7264    }
7265
7266    // ---- Test 16: Tracker manager is populated from torrent metadata ----
7267
7268    #[tokio::test]
7269    async fn tracker_populated_from_metadata() {
7270        use serde::Serialize;
7271
7272        #[derive(Serialize)]
7273        struct Info<'a> {
7274            length: u64,
7275            name: &'a str,
7276            #[serde(rename = "piece length")]
7277            piece_length: u64,
7278            #[serde(with = "serde_bytes")]
7279            pieces: &'a [u8],
7280        }
7281
7282        #[derive(Serialize)]
7283        struct Torrent<'a> {
7284            announce: &'a str,
7285            info: Info<'a>,
7286        }
7287
7288        let data = vec![0xAB; 16384];
7289        let hash = irontide_core::sha1(&data);
7290        let mut pieces = Vec::new();
7291        pieces.extend_from_slice(hash.as_bytes());
7292
7293        let t = Torrent {
7294            announce: "http://tracker.example.com:8080/announce",
7295            info: Info {
7296                length: data.len() as u64,
7297                name: "test",
7298                piece_length: 16384,
7299                pieces: &pieces,
7300            },
7301        };
7302
7303        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7304        let meta = torrent_from_bytes(&bytes).unwrap();
7305        assert!(meta.announce.is_some());
7306
7307        let storage = make_storage(&data, 16384);
7308        let config = test_config();
7309
7310        // The torrent should start and announce to tracker (which will fail since
7311        // the tracker doesn't exist, but that's fine — failures are non-fatal).
7312        let (atx, amask) = test_alert_channel();
7313        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7314        let handle = TorrentHandle::from_torrent(
7315            meta,
7316            irontide_core::TorrentVersion::V1Only,
7317            None,
7318            dh,
7319            dm,
7320            config,
7321            test_dht_rx(),
7322            test_dht_rx(),
7323            None,
7324            None,
7325            crate::slot_tuner::SlotTuner::disabled(4),
7326            atx,
7327            amask,
7328            None,
7329            None,
7330            test_ban_manager(),
7331            test_ip_filter(),
7332            Arc::new(Vec::new()),
7333            None,
7334            None,
7335            Arc::new(crate::transport::NetworkFactory::tokio()),
7336            None, // M96: hash_pool
7337            Arc::new(crate::stats::SessionCounters::new()),
7338        )
7339        .await
7340        .unwrap();
7341
7342        let stats = handle.stats().await.unwrap();
7343        assert_eq!(stats.state, TorrentState::Downloading);
7344
7345        handle.shutdown().await.unwrap();
7346    }
7347
7348    // ---- Test 17: Private torrent with DHT=None works ----
7349
7350    #[tokio::test]
7351    async fn private_torrent_no_dht_field() {
7352        use serde::Serialize;
7353
7354        #[derive(Serialize)]
7355        struct Info<'a> {
7356            length: u64,
7357            name: &'a str,
7358            #[serde(rename = "piece length")]
7359            piece_length: u64,
7360            #[serde(with = "serde_bytes")]
7361            pieces: &'a [u8],
7362            private: i64,
7363        }
7364
7365        #[derive(Serialize)]
7366        struct Torrent<'a> {
7367            announce: &'a str,
7368            info: Info<'a>,
7369        }
7370
7371        let data = vec![0xAB; 16384];
7372        let hash = irontide_core::sha1(&data);
7373        let mut pieces = Vec::new();
7374        pieces.extend_from_slice(hash.as_bytes());
7375
7376        let t = Torrent {
7377            announce: "http://private-tracker.example.com/announce",
7378            info: Info {
7379                length: data.len() as u64,
7380                name: "private_test",
7381                piece_length: 16384,
7382                pieces: &pieces,
7383                private: 1,
7384            },
7385        };
7386
7387        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7388        let meta = torrent_from_bytes(&bytes).unwrap();
7389        assert_eq!(meta.info.private, Some(1));
7390
7391        let storage = make_storage(&data, 16384);
7392        let config = test_config();
7393
7394        let (atx, amask) = test_alert_channel();
7395        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7396        let handle = TorrentHandle::from_torrent(
7397            meta,
7398            irontide_core::TorrentVersion::V1Only,
7399            None,
7400            dh,
7401            dm,
7402            config,
7403            test_dht_rx(),
7404            test_dht_rx(),
7405            None,
7406            None,
7407            crate::slot_tuner::SlotTuner::disabled(4),
7408            atx,
7409            amask,
7410            None,
7411            None,
7412            test_ban_manager(),
7413            test_ip_filter(),
7414            Arc::new(Vec::new()),
7415            None,
7416            None,
7417            Arc::new(crate::transport::NetworkFactory::tokio()),
7418            None, // M96: hash_pool
7419            Arc::new(crate::stats::SessionCounters::new()),
7420        )
7421        .await
7422        .unwrap();
7423
7424        let stats = handle.stats().await.unwrap();
7425        assert_eq!(stats.state, TorrentState::Downloading);
7426
7427        handle.shutdown().await.unwrap();
7428    }
7429
7430    // ---- Test 18: Magnet defers tracker announce ----
7431
7432    #[tokio::test]
7433    async fn magnet_no_tracker_before_metadata() {
7434        let magnet = Magnet {
7435            info_hashes: irontide_core::InfoHashes::v1_only(
7436                Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
7437            ),
7438            display_name: Some("magnet test".into()),
7439            trackers: vec![],
7440            peers: vec![],
7441            selected_files: None,
7442        };
7443
7444        let (atx, amask) = test_alert_channel();
7445        let (dm, _dj) = test_disk_manager();
7446        let handle = TorrentHandle::from_magnet(
7447            magnet,
7448            dm,
7449            test_config(),
7450            test_dht_rx(),
7451            test_dht_rx(),
7452            None,
7453            None,
7454            crate::slot_tuner::SlotTuner::disabled(4),
7455            atx,
7456            amask,
7457            None,
7458            None,
7459            test_ban_manager(),
7460            test_ip_filter(),
7461            Arc::new(Vec::new()),
7462            None,
7463            None,
7464            Arc::new(crate::transport::NetworkFactory::tokio()),
7465            None, // M96: hash_pool
7466            Arc::new(crate::stats::SessionCounters::new()),
7467        )
7468        .await
7469        .unwrap();
7470
7471        let stats = handle.stats().await.unwrap();
7472        assert_eq!(stats.state, TorrentState::FetchingMetadata);
7473
7474        // With no trackers configured, no announces happen regardless of state.
7475        // Note: tracker announces ARE now allowed during FetchingMetadata for
7476        // magnets with &tr= URLs (needed to discover peers before metadata).
7477        tokio::time::sleep(Duration::from_millis(50)).await;
7478
7479        handle.shutdown().await.unwrap();
7480    }
7481
7482    // ---- Test 19: Pause and resume ----
7483
7484    #[tokio::test]
7485    async fn pause_and_resume() {
7486        let data = vec![0xEEu8; 32768];
7487        let meta = make_test_torrent(&data, 16384);
7488        let storage = make_storage(&data, 16384);
7489        let config = test_config();
7490        let (atx, amask) = test_alert_channel();
7491        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7492        let handle = TorrentHandle::from_torrent(
7493            meta,
7494            irontide_core::TorrentVersion::V1Only,
7495            None,
7496            dh,
7497            dm,
7498            config,
7499            test_dht_rx(),
7500            test_dht_rx(),
7501            None,
7502            None,
7503            crate::slot_tuner::SlotTuner::disabled(4),
7504            atx,
7505            amask,
7506            None,
7507            None,
7508            test_ban_manager(),
7509            test_ip_filter(),
7510            Arc::new(Vec::new()),
7511            None,
7512            None,
7513            Arc::new(crate::transport::NetworkFactory::tokio()),
7514            None, // M96: hash_pool
7515            Arc::new(crate::stats::SessionCounters::new()),
7516        )
7517        .await
7518        .unwrap();
7519
7520        let stats = handle.stats().await.unwrap();
7521        assert_eq!(stats.state, TorrentState::Downloading);
7522
7523        handle.pause().await.unwrap();
7524        tokio::time::sleep(Duration::from_millis(50)).await;
7525        let stats = handle.stats().await.unwrap();
7526        assert_eq!(stats.state, TorrentState::Paused);
7527
7528        handle.resume().await.unwrap();
7529        tokio::time::sleep(Duration::from_millis(50)).await;
7530        let stats = handle.stats().await.unwrap();
7531        assert_eq!(stats.state, TorrentState::Downloading);
7532
7533        handle.shutdown().await.unwrap();
7534    }
7535
7536    // ---- Test 20: Pause already paused is noop ----
7537
7538    #[tokio::test]
7539    async fn pause_already_paused_is_noop() {
7540        let data = vec![0xEEu8; 32768];
7541        let meta = make_test_torrent(&data, 16384);
7542        let storage = make_storage(&data, 16384);
7543        let config = test_config();
7544        let (atx, amask) = test_alert_channel();
7545        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7546        let handle = TorrentHandle::from_torrent(
7547            meta,
7548            irontide_core::TorrentVersion::V1Only,
7549            None,
7550            dh,
7551            dm,
7552            config,
7553            test_dht_rx(),
7554            test_dht_rx(),
7555            None,
7556            None,
7557            crate::slot_tuner::SlotTuner::disabled(4),
7558            atx,
7559            amask,
7560            None,
7561            None,
7562            test_ban_manager(),
7563            test_ip_filter(),
7564            Arc::new(Vec::new()),
7565            None,
7566            None,
7567            Arc::new(crate::transport::NetworkFactory::tokio()),
7568            None, // M96: hash_pool
7569            Arc::new(crate::stats::SessionCounters::new()),
7570        )
7571        .await
7572        .unwrap();
7573
7574        handle.pause().await.unwrap();
7575        tokio::time::sleep(Duration::from_millis(50)).await;
7576        handle.pause().await.unwrap(); // double pause is fine
7577        tokio::time::sleep(Duration::from_millis(50)).await;
7578        let stats = handle.stats().await.unwrap();
7579        assert_eq!(stats.state, TorrentState::Paused);
7580
7581        handle.shutdown().await.unwrap();
7582    }
7583
7584    // ---- Test 21: Incoming request served from storage ----
7585    //
7586    // Phase 1: Mock seeder feeds piece 0 to the torrent so it becomes verified.
7587    // Phase 2: Mock leecher connects and requests piece 0, verifying upload pipeline.
7588
7589    #[tokio::test]
7590    async fn incoming_request_served_from_storage() {
7591        let data = vec![0xABu8; 16384];
7592        let meta = make_test_torrent(&data, 16384);
7593        let info_hash = meta.info_hash;
7594        let storage = make_storage(&data, 16384);
7595
7596        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7597        let listen_addr = listener.local_addr().unwrap();
7598        drop(listener);
7599
7600        let config = TorrentConfig {
7601            listen_port: listen_addr.port(),
7602            ..test_config()
7603        };
7604
7605        let (atx, amask) = test_alert_channel();
7606        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7607        let handle = TorrentHandle::from_torrent(
7608            meta,
7609            irontide_core::TorrentVersion::V1Only,
7610            None,
7611            dh,
7612            dm,
7613            config,
7614            test_dht_rx(),
7615            test_dht_rx(),
7616            None,
7617            None,
7618            crate::slot_tuner::SlotTuner::disabled(4),
7619            atx,
7620            amask,
7621            None,
7622            None,
7623            test_ban_manager(),
7624            test_ip_filter(),
7625            Arc::new(Vec::new()),
7626            None,
7627            None,
7628            Arc::new(crate::transport::NetworkFactory::tokio()),
7629            None, // M96: hash_pool
7630            Arc::new(crate::stats::SessionCounters::new()),
7631        )
7632        .await
7633        .unwrap();
7634
7635        tokio::time::sleep(Duration::from_millis(50)).await;
7636
7637        // Phase 1: Seed the torrent with piece 0
7638        let seed_data = data.clone();
7639        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7640        let seeder_task = tokio::spawn(async move {
7641            let (reader, writer) = tokio::io::split(seed_stream);
7642            let mut writer = writer;
7643            let mut reader = reader;
7644
7645            let hs = Handshake::new(
7646                info_hash,
7647                Id20::from_hex("6666666666666666666666666666666666666666").unwrap(),
7648            );
7649            writer.write_all(&hs.to_bytes()).await.unwrap();
7650            writer.flush().await.unwrap();
7651            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7652            reader.read_exact(&mut hs_buf).await.unwrap();
7653
7654            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7655            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7656
7657            let _msg = framed_read.next().await; // ext handshake
7658            let ext_hs = ExtHandshake::new();
7659            let payload = ext_hs.to_bytes().unwrap();
7660            framed_write
7661                .send(Message::Extended { ext_id: 0, payload })
7662                .await
7663                .unwrap();
7664
7665            // Send bitfield + unchoke
7666            let mut bf = Bitfield::new(1);
7667            bf.set(0);
7668            framed_write
7669                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7670                .await
7671                .unwrap();
7672            framed_write.send(Message::Unchoke).await.unwrap();
7673
7674            // Respond to requests
7675            while let Some(Ok(msg)) = framed_read.next().await {
7676                if let Message::Request {
7677                    index,
7678                    begin,
7679                    length,
7680                } = msg
7681                {
7682                    let start = begin as usize;
7683                    let end = start + length as usize;
7684                    framed_write
7685                        .send(Message::Piece {
7686                            index,
7687                            begin,
7688                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7689                            data_1: Bytes::new(),
7690                        })
7691                        .await
7692                        .unwrap();
7693                }
7694            }
7695        });
7696
7697        // Wait for download to complete
7698        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7699        loop {
7700            tokio::time::sleep(Duration::from_millis(100)).await;
7701            let stats = handle.stats().await.unwrap();
7702            if stats.pieces_have == 1 {
7703                break;
7704            }
7705            assert!(
7706                tokio::time::Instant::now() <= deadline,
7707                "piece download did not complete within 5s"
7708            );
7709        }
7710
7711        // Phase 2: Connect a mock leecher to request piece 0 back
7712        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7713        let expected_data = data.clone();
7714        let leecher_task = tokio::spawn(async move {
7715            let (reader, writer) = tokio::io::split(leech_stream);
7716            let mut writer = writer;
7717            let mut reader = reader;
7718
7719            let hs = Handshake::new(
7720                info_hash,
7721                Id20::from_hex("7777777777777777777777777777777777777777").unwrap(),
7722            );
7723            writer.write_all(&hs.to_bytes()).await.unwrap();
7724            writer.flush().await.unwrap();
7725            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7726            reader.read_exact(&mut hs_buf).await.unwrap();
7727
7728            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7729            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7730
7731            let _msg = framed_read.next().await; // ext handshake
7732            let ext_hs = ExtHandshake::new();
7733            let payload = ext_hs.to_bytes().unwrap();
7734            framed_write
7735                .send(Message::Extended { ext_id: 0, payload })
7736                .await
7737                .unwrap();
7738
7739            // Send Interested and wait for Unchoke
7740            framed_write.send(Message::Interested).await.unwrap();
7741
7742            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7743            loop {
7744                tokio::select! {
7745                    msg = framed_read.next() => {
7746                        match msg {
7747                            Some(Ok(Message::Unchoke)) => { break; }
7748                            Some(Ok(_)) => {}
7749                            _ => panic!("connection closed before unchoke"),
7750                        }
7751                    }
7752                    () = tokio::time::sleep_until(deadline) => {
7753                        panic!("timed out waiting for unchoke");
7754                    }
7755                }
7756            }
7757
7758            // Request piece 0
7759            framed_write
7760                .send(Message::Request {
7761                    index: 0,
7762                    begin: 0,
7763                    length: 16384,
7764                })
7765                .await
7766                .unwrap();
7767
7768            // Read Piece response
7769            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7770            loop {
7771                tokio::select! {
7772                    msg = framed_read.next() => {
7773                        match msg {
7774                            Some(Ok(Message::Piece { index, begin, data_0, data_1 })) => {
7775                                assert_eq!(index, 0);
7776                                assert_eq!(begin, 0);
7777                                let _ = &data_1; // empty after wire round-trip
7778                                assert_eq!(data_0.as_ref(), expected_data.as_slice());
7779                                return; // success
7780                            }
7781                            Some(Ok(_)) => {}
7782                            Some(Err(e)) => panic!("error reading: {e}"),
7783                            None => panic!("connection closed before piece"),
7784                        }
7785                    }
7786                    () = tokio::time::sleep_until(deadline) => {
7787                        panic!("timed out waiting for piece data");
7788                    }
7789                }
7790            }
7791        });
7792
7793        // Wait for leecher to complete
7794        let result = tokio::time::timeout(Duration::from_secs(20), leecher_task).await;
7795        match result {
7796            Ok(Ok(())) => {}
7797            Ok(Err(e)) => panic!("leecher task panicked: {e}"),
7798            Err(elapsed) => panic!("test timed out after {elapsed}"),
7799        }
7800
7801        // Verify uploaded bytes
7802        let stats = handle.stats().await.unwrap();
7803        assert!(
7804            stats.uploaded > 0,
7805            "expected uploaded > 0, got {}",
7806            stats.uploaded
7807        );
7808
7809        handle.shutdown().await.unwrap();
7810        seeder_task.abort();
7811    }
7812
7813    // ---- Test 22: Seed ratio limit stops torrent ----
7814
7815    #[tokio::test]
7816    async fn seed_ratio_limit_stops_torrent() {
7817        // 1-piece torrent, ratio limit = 1.0
7818        // After downloading 16384 bytes and uploading 16384 bytes, ratio = 1.0 → stop
7819        let data = vec![0xCCu8; 16384];
7820        let meta = make_test_torrent(&data, 16384);
7821        let info_hash = meta.info_hash;
7822        let storage = make_storage(&data, 16384);
7823
7824        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7825        let listen_addr = listener.local_addr().unwrap();
7826        drop(listener);
7827
7828        let config = TorrentConfig {
7829            listen_port: listen_addr.port(),
7830            seed_ratio_limit: Some(1.0),
7831            ..test_config()
7832        };
7833
7834        let (atx, amask) = test_alert_channel();
7835        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7836        let handle = TorrentHandle::from_torrent(
7837            meta,
7838            irontide_core::TorrentVersion::V1Only,
7839            None,
7840            dh,
7841            dm,
7842            config,
7843            test_dht_rx(),
7844            test_dht_rx(),
7845            None,
7846            None,
7847            crate::slot_tuner::SlotTuner::disabled(4),
7848            atx,
7849            amask,
7850            None,
7851            None,
7852            test_ban_manager(),
7853            test_ip_filter(),
7854            Arc::new(Vec::new()),
7855            None,
7856            None,
7857            Arc::new(crate::transport::NetworkFactory::tokio()),
7858            None, // M96: hash_pool
7859            Arc::new(crate::stats::SessionCounters::new()),
7860        )
7861        .await
7862        .unwrap();
7863
7864        tokio::time::sleep(Duration::from_millis(50)).await;
7865
7866        // Phase 1: Seed the torrent with piece 0
7867        let seed_data = data.clone();
7868        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7869        let seeder_task = tokio::spawn(async move {
7870            let (reader, writer) = tokio::io::split(seed_stream);
7871            let mut writer = writer;
7872            let mut reader = reader;
7873
7874            let hs = Handshake::new(
7875                info_hash,
7876                Id20::from_hex("8888888888888888888888888888888888888888").unwrap(),
7877            );
7878            writer.write_all(&hs.to_bytes()).await.unwrap();
7879            writer.flush().await.unwrap();
7880            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7881            reader.read_exact(&mut hs_buf).await.unwrap();
7882
7883            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7884            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7885
7886            let _msg = framed_read.next().await;
7887            let ext_hs = ExtHandshake::new();
7888            let payload = ext_hs.to_bytes().unwrap();
7889            framed_write
7890                .send(Message::Extended { ext_id: 0, payload })
7891                .await
7892                .unwrap();
7893
7894            let mut bf = Bitfield::new(1);
7895            bf.set(0);
7896            framed_write
7897                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7898                .await
7899                .unwrap();
7900            framed_write.send(Message::Unchoke).await.unwrap();
7901
7902            while let Some(Ok(msg)) = framed_read.next().await {
7903                if let Message::Request {
7904                    index,
7905                    begin,
7906                    length,
7907                } = msg
7908                {
7909                    let start = begin as usize;
7910                    let end = start + length as usize;
7911                    framed_write
7912                        .send(Message::Piece {
7913                            index,
7914                            begin,
7915                            data_0: Bytes::copy_from_slice(&seed_data[start..end]),
7916                            data_1: Bytes::new(),
7917                        })
7918                        .await
7919                        .unwrap();
7920                }
7921            }
7922        });
7923
7924        // Wait for download to complete (transitions to Seeding)
7925        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
7926        loop {
7927            tokio::time::sleep(Duration::from_millis(100)).await;
7928            let stats = handle.stats().await.unwrap();
7929            if stats.state == TorrentState::Seeding {
7930                break;
7931            }
7932            assert!(
7933                tokio::time::Instant::now() <= deadline,
7934                "download did not complete within 5s"
7935            );
7936        }
7937
7938        // Phase 2: Connect leecher to request piece 0 — this should trigger ratio limit
7939        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7940        let leecher_task = tokio::spawn(async move {
7941            let (reader, writer) = tokio::io::split(leech_stream);
7942            let mut writer = writer;
7943            let mut reader = reader;
7944
7945            let hs = Handshake::new(
7946                info_hash,
7947                Id20::from_hex("9999999999999999999999999999999999999999").unwrap(),
7948            );
7949            writer.write_all(&hs.to_bytes()).await.unwrap();
7950            writer.flush().await.unwrap();
7951            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7952            reader.read_exact(&mut hs_buf).await.unwrap();
7953
7954            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7955            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7956
7957            let _msg = framed_read.next().await;
7958            let ext_hs = ExtHandshake::new();
7959            let payload = ext_hs.to_bytes().unwrap();
7960            framed_write
7961                .send(Message::Extended { ext_id: 0, payload })
7962                .await
7963                .unwrap();
7964
7965            framed_write.send(Message::Interested).await.unwrap();
7966
7967            // Wait for unchoke
7968            let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7969            loop {
7970                tokio::select! {
7971                    msg = framed_read.next() => {
7972                        match msg {
7973                            Some(Ok(Message::Unchoke)) => break,
7974                            Some(Ok(_)) => {}
7975                            _ => return, // connection may close due to ratio shutdown
7976                        }
7977                    }
7978                    () = tokio::time::sleep_until(deadline) => return,
7979                }
7980            }
7981
7982            // Request piece 0
7983            framed_write
7984                .send(Message::Request {
7985                    index: 0,
7986                    begin: 0,
7987                    length: 16384,
7988                })
7989                .await
7990                .unwrap();
7991
7992            // Read until connection closes (the torrent may stop and disconnect us)
7993            while let Some(Ok(_msg)) = framed_read.next().await {}
7994        });
7995
7996        // Wait for state to become Stopped
7997        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
7998        loop {
7999            tokio::time::sleep(Duration::from_millis(100)).await;
8000            let stats = handle.stats().await.unwrap();
8001            if stats.state == TorrentState::Stopped {
8002                assert!(
8003                    stats.uploaded >= 16384,
8004                    "expected uploaded >= 16384, got {}",
8005                    stats.uploaded
8006                );
8007                break;
8008            }
8009            if tokio::time::Instant::now() > deadline {
8010                let stats = handle.stats().await.unwrap();
8011                panic!(
8012                    "expected Stopped, got {:?}, uploaded={}, downloaded={}",
8013                    stats.state, stats.uploaded, stats.downloaded
8014                );
8015            }
8016        }
8017
8018        handle.shutdown().await.unwrap();
8019        seeder_task.abort();
8020        leecher_task.abort();
8021    }
8022
8023    // ---- Test 23: Resume with seeded storage starts as seeder ----
8024
8025    #[tokio::test]
8026    async fn resume_with_seeded_storage() {
8027        let data = vec![0xDDu8; 32768]; // 2 pieces
8028        let meta = make_test_torrent(&data, 16384);
8029        let storage = make_seeded_storage(&data, 16384);
8030        let config = test_config();
8031
8032        let (atx, amask) = test_alert_channel();
8033        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8034        let handle = TorrentHandle::from_torrent(
8035            meta,
8036            irontide_core::TorrentVersion::V1Only,
8037            None,
8038            dh,
8039            dm,
8040            config,
8041            test_dht_rx(),
8042            test_dht_rx(),
8043            None,
8044            None,
8045            crate::slot_tuner::SlotTuner::disabled(4),
8046            atx,
8047            amask,
8048            None,
8049            None,
8050            test_ban_manager(),
8051            test_ip_filter(),
8052            Arc::new(Vec::new()),
8053            None,
8054            None,
8055            Arc::new(crate::transport::NetworkFactory::tokio()),
8056            None, // M96: hash_pool
8057            Arc::new(crate::stats::SessionCounters::new()),
8058        )
8059        .await
8060        .unwrap();
8061
8062        // Give the actor time to verify existing pieces
8063        tokio::time::sleep(Duration::from_millis(100)).await;
8064
8065        let stats = handle.stats().await.unwrap();
8066        assert_eq!(
8067            stats.state,
8068            TorrentState::Seeding,
8069            "should start as seeder with all pieces verified"
8070        );
8071        assert_eq!(stats.pieces_have, 2);
8072        assert_eq!(stats.pieces_total, 2);
8073
8074        handle.shutdown().await.unwrap();
8075    }
8076
8077    // ---- Test: save_resume_data captures state ----
8078
8079    #[tokio::test]
8080    async fn save_resume_data_captures_state() {
8081        let data = vec![0xAB; 32768];
8082        let meta = make_test_torrent(&data, 16384);
8083        let info_hash = meta.info_hash;
8084        let storage = make_storage(&data, 16384);
8085        let config = test_config();
8086
8087        let (atx, amask) = test_alert_channel();
8088        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8089        let handle = TorrentHandle::from_torrent(
8090            meta,
8091            irontide_core::TorrentVersion::V1Only,
8092            None,
8093            dh,
8094            dm,
8095            config,
8096            test_dht_rx(),
8097            test_dht_rx(),
8098            None,
8099            None,
8100            crate::slot_tuner::SlotTuner::disabled(4),
8101            atx,
8102            amask,
8103            None,
8104            None,
8105            test_ban_manager(),
8106            test_ip_filter(),
8107            Arc::new(Vec::new()),
8108            None,
8109            None,
8110            Arc::new(crate::transport::NetworkFactory::tokio()),
8111            None, // M96: hash_pool
8112            Arc::new(crate::stats::SessionCounters::new()),
8113        )
8114        .await
8115        .unwrap();
8116
8117        // Give actor time to start
8118        tokio::time::sleep(Duration::from_millis(50)).await;
8119
8120        let rd = handle.save_resume_data().await.unwrap();
8121
8122        assert_eq!(rd.file_format, "libtorrent resume file");
8123        assert_eq!(rd.file_version, 1);
8124        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8125        assert_eq!(rd.name, "test");
8126        assert_eq!(rd.save_path, "/tmp");
8127        assert_eq!(rd.paused, 0);
8128        // No pieces downloaded yet — bitfield should be all zeros
8129        assert!(!rd.pieces.is_empty());
8130        // Stats should be zero for a freshly started torrent with no peers
8131        assert_eq!(rd.total_uploaded, 0);
8132        assert_eq!(rd.total_downloaded, 0);
8133
8134        handle.shutdown().await.unwrap();
8135    }
8136
8137    // ---- Test: save_resume_data for seeder ----
8138
8139    #[tokio::test]
8140    async fn save_resume_data_seeder() {
8141        let data = vec![0xCD; 32768];
8142        let meta = make_test_torrent(&data, 16384);
8143        let info_hash = meta.info_hash;
8144        let storage = make_seeded_storage(&data, 16384);
8145        let config = test_config();
8146
8147        let (atx, amask) = test_alert_channel();
8148        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8149        let handle = TorrentHandle::from_torrent(
8150            meta,
8151            irontide_core::TorrentVersion::V1Only,
8152            None,
8153            dh,
8154            dm,
8155            config,
8156            test_dht_rx(),
8157            test_dht_rx(),
8158            None,
8159            None,
8160            crate::slot_tuner::SlotTuner::disabled(4),
8161            atx,
8162            amask,
8163            None,
8164            None,
8165            test_ban_manager(),
8166            test_ip_filter(),
8167            Arc::new(Vec::new()),
8168            None,
8169            None,
8170            Arc::new(crate::transport::NetworkFactory::tokio()),
8171            None, // M96: hash_pool
8172            Arc::new(crate::stats::SessionCounters::new()),
8173        )
8174        .await
8175        .unwrap();
8176
8177        // Give actor time to verify pieces and switch to seeding
8178        tokio::time::sleep(Duration::from_millis(100)).await;
8179
8180        let rd = handle.save_resume_data().await.unwrap();
8181
8182        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
8183        assert_eq!(rd.name, "test");
8184        assert_eq!(rd.seed_mode, 1, "seeder should have seed_mode=1");
8185        assert_eq!(rd.paused, 0);
8186        // All pieces should be marked in the bitfield
8187        // 2 pieces -> 1 byte, top 2 bits set = 0b1100_0000 = 0xC0
8188        assert_eq!(rd.pieces.len(), 1);
8189        assert_eq!(
8190            rd.pieces[0] & 0xC0,
8191            0xC0,
8192            "both pieces should be marked complete"
8193        );
8194
8195        handle.shutdown().await.unwrap();
8196    }
8197
8198    // ---- Test: save_resume_data for paused torrent ----
8199
8200    #[tokio::test]
8201    async fn save_resume_data_paused() {
8202        let data = vec![0xEF; 16384];
8203        let meta = make_test_torrent(&data, 16384);
8204        let storage = make_storage(&data, 16384);
8205        let config = test_config();
8206
8207        let (atx, amask) = test_alert_channel();
8208        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8209        let handle = TorrentHandle::from_torrent(
8210            meta,
8211            irontide_core::TorrentVersion::V1Only,
8212            None,
8213            dh,
8214            dm,
8215            config,
8216            test_dht_rx(),
8217            test_dht_rx(),
8218            None,
8219            None,
8220            crate::slot_tuner::SlotTuner::disabled(4),
8221            atx,
8222            amask,
8223            None,
8224            None,
8225            test_ban_manager(),
8226            test_ip_filter(),
8227            Arc::new(Vec::new()),
8228            None,
8229            None,
8230            Arc::new(crate::transport::NetworkFactory::tokio()),
8231            None, // M96: hash_pool
8232            Arc::new(crate::stats::SessionCounters::new()),
8233        )
8234        .await
8235        .unwrap();
8236
8237        tokio::time::sleep(Duration::from_millis(50)).await;
8238        handle.pause().await.unwrap();
8239        tokio::time::sleep(Duration::from_millis(50)).await;
8240
8241        let rd = handle.save_resume_data().await.unwrap();
8242        assert_eq!(rd.paused, 1, "paused torrent should have paused=1");
8243        assert_eq!(rd.seed_mode, 0);
8244
8245        handle.shutdown().await.unwrap();
8246    }
8247
8248    // ---- Test: set_file_priority and read back ----
8249
8250    #[tokio::test]
8251    async fn set_file_priority_and_read_back() {
8252        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8253        let mut torrent_bytes = b"d4:info".to_vec();
8254        torrent_bytes.extend_from_slice(info_bytes);
8255        torrent_bytes.push(b'e');
8256
8257        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8258        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8259        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8260        let config = TorrentConfig {
8261            listen_port: 0,
8262            ..Default::default()
8263        };
8264
8265        let (atx, amask) = test_alert_channel();
8266        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8267        let handle = TorrentHandle::from_torrent(
8268            meta,
8269            irontide_core::TorrentVersion::V1Only,
8270            None,
8271            dh,
8272            dm,
8273            config,
8274            test_dht_rx(),
8275            test_dht_rx(),
8276            None,
8277            None,
8278            crate::slot_tuner::SlotTuner::disabled(4),
8279            atx,
8280            amask,
8281            None,
8282            None,
8283            test_ban_manager(),
8284            test_ip_filter(),
8285            Arc::new(Vec::new()),
8286            None,
8287            None,
8288            Arc::new(crate::transport::NetworkFactory::tokio()),
8289            None, // M96: hash_pool
8290            Arc::new(crate::stats::SessionCounters::new()),
8291        )
8292        .await
8293        .unwrap();
8294
8295        // Default priorities should all be Normal
8296        let prios = handle.file_priorities().await.unwrap();
8297        assert_eq!(prios.len(), 2);
8298        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8299
8300        // Set file 0 to Skip
8301        handle
8302            .set_file_priority(0, FilePriority::Skip)
8303            .await
8304            .unwrap();
8305
8306        let prios = handle.file_priorities().await.unwrap();
8307        assert_eq!(prios[0], FilePriority::Skip);
8308        assert_eq!(prios[1], FilePriority::Normal);
8309
8310        // Invalid index should error
8311        let result = handle.set_file_priority(99, FilePriority::High).await;
8312        assert!(result.is_err());
8313
8314        handle.shutdown().await.unwrap();
8315        tokio::time::sleep(Duration::from_millis(50)).await;
8316    }
8317
8318    /// Spawn a running multi-file torrent (3 files, 4 pieces, one shared
8319    /// boundary piece: file b at 150 B spans pieces 1-2, file c spans 2-3) for
8320    /// end-to-end `SetFilePriority` arm coverage.
8321    async fn spawn_test_torrent_multifile() -> TorrentHandle {
8322        let meta = make_multi_file_meta(&[(100, "a.bin"), (150, "b.bin"), (100, "c.bin")], 100);
8323        let lengths = Lengths::new(350, 100, DEFAULT_CHUNK_SIZE);
8324        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8325        let config = TorrentConfig {
8326            listen_port: 0,
8327            ..Default::default()
8328        };
8329        let (atx, amask) = test_alert_channel();
8330        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8331        TorrentHandle::from_torrent(
8332            meta,
8333            irontide_core::TorrentVersion::V1Only,
8334            None,
8335            dh,
8336            dm,
8337            config,
8338            test_dht_rx(),
8339            test_dht_rx(),
8340            None,
8341            None,
8342            crate::slot_tuner::SlotTuner::disabled(4),
8343            atx,
8344            amask,
8345            None,
8346            None,
8347            test_ban_manager(),
8348            test_ip_filter(),
8349            Arc::new(Vec::new()),
8350            None,
8351            None,
8352            Arc::new(crate::transport::NetworkFactory::tokio()),
8353            None,
8354            Arc::new(crate::stats::SessionCounters::new()),
8355        )
8356        .await
8357        .unwrap()
8358    }
8359
8360    /// M246 characterization: the observable post-conditions of a file-priority
8361    /// change — `file_priorities()` round-trip and the invalid-index error — are
8362    /// synchronous and MUST survive the recv-loop hardening. Only the order-map
8363    /// *build* moves to the 1 s tick; the priority/wanted/atomic state that gates
8364    /// dispatch stays synchronous, so this surface is unchanged before and after.
8365    #[tokio::test]
8366    async fn set_file_priority_updates_wanted_and_priorities() {
8367        let handle = spawn_test_torrent_multifile().await;
8368
8369        let prios = handle.file_priorities().await.unwrap();
8370        assert_eq!(prios.len(), 3);
8371        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
8372
8373        handle
8374            .set_file_priority(1, FilePriority::Skip)
8375            .await
8376            .unwrap();
8377        assert_eq!(
8378            handle.file_priorities().await.unwrap()[1],
8379            FilePriority::Skip
8380        );
8381
8382        handle
8383            .set_file_priority(1, FilePriority::Normal)
8384            .await
8385            .unwrap();
8386        assert_eq!(
8387            handle.file_priorities().await.unwrap()[1],
8388            FilePriority::Normal
8389        );
8390
8391        // Invalid index still errors synchronously.
8392        assert!(
8393            handle
8394                .set_file_priority(99, FilePriority::High)
8395                .await
8396                .is_err()
8397        );
8398
8399        handle.shutdown().await.unwrap();
8400        tokio::time::sleep(Duration::from_millis(50)).await;
8401    }
8402
8403    /// Build a synchronous (non-spawned) multi-file `TorrentActor` for direct
8404    /// method-level testing of the M246 range-scoped helpers. Populates
8405    /// `meta`, `lengths`, `cached_files`, `file_priorities` (all `Normal`),
8406    /// `atomic_states`, and `piece_tracker` sized to the layout, on top of
8407    /// `for_throttle_test`'s channel scaffolding. Must run inside a tokio runtime.
8408    fn priority_test_actor(files: &[(u64, &str)], piece_length: u64) -> TorrentActor {
8409        use irontide_storage::Bitfield;
8410        let meta = make_multi_file_meta(files, piece_length);
8411        let total: u64 = files.iter().map(|(l, _)| *l).sum();
8412        let lengths = Lengths::new(total, piece_length, DEFAULT_CHUNK_SIZE);
8413        let num_pieces = lengths.num_pieces();
8414        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8415
8416        let mut actor = TorrentActor::for_throttle_test(num_pieces, 0);
8417        actor.file_priorities = vec![FilePriority::Normal; files.len()];
8418        actor.wanted_pieces = crate::piece_selector::build_wanted_pieces(
8419            &actor.file_priorities,
8420            &file_lengths,
8421            &lengths,
8422        );
8423        actor.cached_files = Some(build_cached_file_info(&meta, &lengths));
8424
8425        let we_have = Bitfield::new(num_pieces);
8426        actor.atomic_states = Some(Arc::new(crate::piece_reservation::AtomicPieceStates::new(
8427            num_pieces,
8428            &we_have,
8429            &actor.wanted_pieces,
8430        )));
8431        actor.piece_tracker = Some(crate::piece_reservation::PieceTracker::new(
8432            num_pieces,
8433            &we_have,
8434            &actor.wanted_pieces,
8435        ));
8436        actor.meta = Some(meta);
8437        actor.lengths = Some(lengths);
8438        actor
8439    }
8440
8441    /// M246 D4: the scoped recompute must leave `wanted_pieces` byte-identical
8442    /// to a full `build_wanted_pieces` for every file, across a misaligned
8443    /// layout with multiple shared boundary pieces (b/c share piece 2; c/d
8444    /// share piece 3).
8445    #[tokio::test]
8446    async fn apply_file_priority_scoped_matches_full_rebuild() {
8447        let files: &[(u64, &str)] = &[(100, "a"), (150, "b"), (100, "c"), (250, "d")];
8448        let piece_length = 100;
8449        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8450        for skip_idx in 0..files.len() {
8451            let mut actor = priority_test_actor(files, piece_length);
8452            actor
8453                .apply_file_priority_scoped(skip_idx, FilePriority::Skip)
8454                .unwrap();
8455
8456            let mut ref_prios = vec![FilePriority::Normal; files.len()];
8457            ref_prios[skip_idx] = FilePriority::Skip;
8458            let reference = crate::piece_selector::build_wanted_pieces(
8459                &ref_prios,
8460                &file_lengths,
8461                actor.lengths.as_ref().unwrap(),
8462            );
8463            for p in 0..actor.num_pieces {
8464                assert_eq!(
8465                    actor.wanted_pieces.get(p),
8466                    reference.get(p),
8467                    "piece {p} mismatch after scoped skip of file {skip_idx}"
8468                );
8469            }
8470        }
8471    }
8472
8473    /// M246 D4: sub-piece-sized files (a,b,c all inside piece 0). The shared
8474    /// piece stays wanted while ANY overlapping file is non-skip, and unwants
8475    /// only when all overlapping files are skipped.
8476    #[tokio::test]
8477    async fn apply_file_priority_scoped_handles_sub_piece_files() {
8478        let files: &[(u64, &str)] = &[(30, "a"), (30, "b"), (40, "c"), (100, "d")];
8479        let mut actor = priority_test_actor(files, 100);
8480
8481        actor
8482            .apply_file_priority_scoped(0, FilePriority::Skip)
8483            .unwrap();
8484        assert!(
8485            actor.wanted_pieces.get(0),
8486            "piece 0 wanted: b,c still want it"
8487        );
8488        actor
8489            .apply_file_priority_scoped(1, FilePriority::Skip)
8490            .unwrap();
8491        actor
8492            .apply_file_priority_scoped(2, FilePriority::Skip)
8493            .unwrap();
8494        assert!(
8495            !actor.wanted_pieces.get(0),
8496            "piece 0 unwanted: a,b,c all skip"
8497        );
8498        assert!(
8499            actor.wanted_pieces.get(1),
8500            "piece 1 still wanted (d Normal)"
8501        );
8502
8503        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8504        let prios = vec![
8505            FilePriority::Skip,
8506            FilePriority::Skip,
8507            FilePriority::Skip,
8508            FilePriority::Normal,
8509        ];
8510        let reference = crate::piece_selector::build_wanted_pieces(
8511            &prios,
8512            &file_lengths,
8513            actor.lengths.as_ref().unwrap(),
8514        );
8515        for p in 0..actor.num_pieces {
8516            assert_eq!(
8517                actor.wanted_pieces.get(p),
8518                reference.get(p),
8519                "piece {p} mismatch"
8520            );
8521        }
8522    }
8523
8524    /// M246 D4: a zero-length file (middle of the layout) yields an empty range
8525    /// and must not panic; the result still matches a full rebuild.
8526    #[tokio::test]
8527    async fn apply_file_priority_scoped_zero_length_file_no_panic() {
8528        let files: &[(u64, &str)] = &[(100, "a"), (0, "empty"), (100, "c")];
8529        let mut actor = priority_test_actor(files, 100);
8530
8531        let r = actor
8532            .apply_file_priority_scoped(1, FilePriority::Skip)
8533            .unwrap();
8534        assert!(
8535            r.0 > r.1,
8536            "zero-length file yields an empty range, got {r:?}"
8537        );
8538
8539        actor
8540            .apply_file_priority_scoped(0, FilePriority::Skip)
8541            .unwrap();
8542
8543        let file_lengths: Vec<u64> = files.iter().map(|(l, _)| *l).collect();
8544        let prios = vec![FilePriority::Skip, FilePriority::Skip, FilePriority::Normal];
8545        let reference = crate::piece_selector::build_wanted_pieces(
8546            &prios,
8547            &file_lengths,
8548            actor.lengths.as_ref().unwrap(),
8549        );
8550        for p in 0..actor.num_pieces {
8551            assert_eq!(
8552                actor.wanted_pieces.get(p),
8553                reference.get(p),
8554                "piece {p} mismatch"
8555            );
8556        }
8557    }
8558
8559    /// M246: `sync_piece_states_for_range` touches ONLY pieces inside the range;
8560    /// pieces outside stay at their prior atomic state.
8561    #[tokio::test]
8562    async fn sync_piece_states_for_range_only_touches_range() {
8563        let files: &[(u64, &str)] = &[(200, "a"), (200, "b"), (200, "c")];
8564        let mut actor = priority_test_actor(files, 100); // 6 pieces; file 1 = pieces 2,3
8565        let (first, last) = actor
8566            .apply_file_priority_scoped(1, FilePriority::Skip)
8567            .unwrap();
8568        assert_eq!((first, last), (2, 3));
8569        actor.sync_piece_states_for_range(first, last);
8570
8571        let atomic = actor.atomic_states.as_ref().unwrap();
8572        assert_eq!(
8573            atomic.get(2),
8574            crate::piece_reservation::PieceState::Unwanted
8575        );
8576        assert_eq!(
8577            atomic.get(3),
8578            crate::piece_reservation::PieceState::Unwanted
8579        );
8580        for p in [0u32, 1, 4, 5] {
8581            assert_eq!(
8582                atomic.get(p),
8583                crate::piece_reservation::PieceState::Available,
8584                "piece {p} outside the range must be untouched"
8585            );
8586        }
8587    }
8588
8589    /// M246 FINDING-1 regression: the order-map rebuild is DEFERRED (no rebuild
8590    /// before the tick), a batch coalesces to ONE rebuild, the generation comes
8591    /// from the actor-owned monotone counter (advances by exactly 1), and the
8592    /// SECOND of two back-to-back batch changes is NOT lost — the failure mode
8593    /// of the rejected Candidate H (read published gen, async publish → drop).
8594    #[tokio::test]
8595    async fn order_map_coalesces_and_gen_is_monotone() {
8596        // 3 files × 200 B, piece_length 100 → f0=[0,1], f1=[2,3], f2=[4,5].
8597        let mut actor = priority_test_actor(&[(200, "a"), (200, "b"), (200, "c")], 100);
8598        let gen0 = actor.order_map_tx.borrow().generation;
8599
8600        // Two back-to-back scoped priority changes (the batch case). The arm sets
8601        // the dirty flag; we drive the helper + flag directly here.
8602        actor
8603            .apply_file_priority_scoped(0, FilePriority::Skip)
8604            .unwrap();
8605        actor.order_map_dirty = true;
8606        actor
8607            .apply_file_priority_scoped(2, FilePriority::Skip)
8608            .unwrap();
8609        actor.order_map_dirty = true;
8610        assert_eq!(
8611            actor.order_map_tx.borrow().generation,
8612            gen0,
8613            "no order-map rebuild before the tick"
8614        );
8615
8616        // The tick rebuilds ONCE, capturing BOTH changes; gen advances by 1.
8617        actor.rebuild_order_map_now();
8618        assert_eq!(
8619            actor.order_map_tx.borrow().generation,
8620            gen0 + 1,
8621            "exactly one coalesced rebuild"
8622        );
8623        assert!(!actor.order_map_dirty, "dirty flag cleared after rebuild");
8624
8625        // Both skipped files' pieces are absent; file 1's remain present — proof
8626        // the second change was not dropped.
8627        let map = actor.order_map_tx.borrow();
8628        for p in [0u32, 1, 4, 5] {
8629            assert!(
8630                !map.order.contains(&p),
8631                "skipped piece {p} must be absent from the order"
8632            );
8633        }
8634        for p in [2u32, 3] {
8635            assert!(
8636                map.order.contains(&p),
8637                "wanted piece {p} must be present in the order"
8638            );
8639        }
8640    }
8641
8642    /// M246 Q6: the post-recheck reconfigure path delegates to
8643    /// `rebuild_order_map_now`, which clears a pending dirty flag so the next
8644    /// tick does not redundantly rebuild.
8645    #[tokio::test]
8646    async fn rebuild_order_map_now_clears_dirty_flag() {
8647        let mut actor = priority_test_actor(&[(200, "a"), (200, "b")], 100);
8648        actor.order_map_dirty = true;
8649        actor.rebuild_order_map_now();
8650        assert!(!actor.order_map_dirty);
8651    }
8652
8653    #[tokio::test]
8654    async fn resume_data_preserves_file_priorities() {
8655        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
8656        let mut torrent_bytes = b"d4:info".to_vec();
8657        torrent_bytes.extend_from_slice(info_bytes);
8658        torrent_bytes.push(b'e');
8659
8660        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
8661        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
8662        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
8663        let config = TorrentConfig {
8664            listen_port: 0,
8665            ..Default::default()
8666        };
8667
8668        let (atx, amask) = test_alert_channel();
8669        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8670        let handle = TorrentHandle::from_torrent(
8671            meta,
8672            irontide_core::TorrentVersion::V1Only,
8673            None,
8674            dh,
8675            dm,
8676            config,
8677            test_dht_rx(),
8678            test_dht_rx(),
8679            None,
8680            None,
8681            crate::slot_tuner::SlotTuner::disabled(4),
8682            atx,
8683            amask,
8684            None,
8685            None,
8686            test_ban_manager(),
8687            test_ip_filter(),
8688            Arc::new(Vec::new()),
8689            None,
8690            None,
8691            Arc::new(crate::transport::NetworkFactory::tokio()),
8692            None, // M96: hash_pool
8693            Arc::new(crate::stats::SessionCounters::new()),
8694        )
8695        .await
8696        .unwrap();
8697
8698        // Set file priorities
8699        handle
8700            .set_file_priority(0, FilePriority::High)
8701            .await
8702            .unwrap();
8703        handle
8704            .set_file_priority(1, FilePriority::Skip)
8705            .await
8706            .unwrap();
8707
8708        // Save resume data
8709        let rd = handle.save_resume_data().await.unwrap();
8710        assert_eq!(rd.file_priority, vec![7, 0]); // High=7, Skip=0
8711
8712        // Verify bencode round-trip
8713        let encoded = irontide_bencode::to_bytes(&rd).unwrap();
8714        let decoded: irontide_core::FastResumeData =
8715            irontide_bencode::from_bytes(&encoded).unwrap();
8716        assert_eq!(decoded.file_priority, vec![7, 0]);
8717
8718        handle.shutdown().await.unwrap();
8719        tokio::time::sleep(Duration::from_millis(50)).await;
8720    }
8721
8722    // ---- Rate limiting integration tests (M14) ----
8723
8724    #[tokio::test]
8725    async fn upload_rate_limiting_caps_throughput() {
8726        // Test that per-torrent upload rate limiting gates serve_incoming_requests.
8727        // We use a very low rate (1 KB/s) so the 16 KB piece requires ~16 seconds.
8728        // We verify: 1) piece does NOT arrive within 200ms (bucket too small),
8729        //            2) the torrent actor is alive and functional.
8730        let data = vec![0xAB; 16384]; // 1 piece
8731        let meta = make_test_torrent(&data, 16384);
8732        let info_hash = meta.info_hash;
8733        let storage = make_seeded_storage(&data, 16384);
8734
8735        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8736        let listen_addr = listener.local_addr().unwrap();
8737
8738        let config = TorrentConfig {
8739            listen_port: listen_addr.port(),
8740            upload_rate_limit: 1024, // 1 KB/s — way too slow for 16 KB chunk
8741            ..test_config()
8742        };
8743
8744        drop(listener);
8745        let (atx, amask) = test_alert_channel();
8746        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8747        let handle = TorrentHandle::from_torrent(
8748            meta,
8749            irontide_core::TorrentVersion::V1Only,
8750            None,
8751            dh,
8752            dm,
8753            config,
8754            test_dht_rx(),
8755            test_dht_rx(),
8756            None,
8757            None,
8758            crate::slot_tuner::SlotTuner::disabled(4),
8759            atx,
8760            amask,
8761            None,
8762            None,
8763            test_ban_manager(),
8764            test_ip_filter(),
8765            Arc::new(Vec::new()),
8766            None,
8767            None,
8768            Arc::new(crate::transport::NetworkFactory::tokio()),
8769            None, // M96: hash_pool
8770            Arc::new(crate::stats::SessionCounters::new()),
8771        )
8772        .await
8773        .unwrap();
8774
8775        tokio::time::sleep(Duration::from_millis(50)).await;
8776
8777        // Connect mock leecher (raw handshake + framed messages)
8778        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8779        let (reader, writer) = tokio::io::split(stream);
8780        let mut writer = writer;
8781        let mut reader = reader;
8782
8783        let hs = Handshake::new(
8784            info_hash,
8785            Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8786        );
8787        writer.write_all(&hs.to_bytes()).await.unwrap();
8788        writer.flush().await.unwrap();
8789        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8790        reader.read_exact(&mut hs_buf).await.unwrap();
8791
8792        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8793        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8794
8795        // Read ext handshake + bitfield
8796        let _msg = framed_read.next().await;
8797        let ext_hs = ExtHandshake::new();
8798        let payload = ext_hs.to_bytes().unwrap();
8799        framed_write
8800            .send(Message::Extended { ext_id: 0, payload })
8801            .await
8802            .unwrap();
8803
8804        // Read the bitfield
8805        let _bf_msg = framed_read.next().await;
8806
8807        // Express interest
8808        framed_write.send(Message::Interested).await.unwrap();
8809
8810        // Wait for unchoke
8811        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
8812        loop {
8813            tokio::select! {
8814                msg = framed_read.next() => {
8815                    match msg {
8816                        Some(Ok(Message::Unchoke)) => break,
8817                        Some(Ok(_)) => {}
8818                        _ => panic!("connection closed before unchoke"),
8819                    }
8820                }
8821                () = tokio::time::sleep_until(deadline) => {
8822                    panic!("timed out waiting for unchoke");
8823                }
8824            }
8825        }
8826
8827        // Request piece 0
8828        framed_write
8829            .send(Message::Request {
8830                index: 0,
8831                begin: 0,
8832                length: 16384,
8833            })
8834            .await
8835            .unwrap();
8836
8837        // At 1 KB/s, the bucket accumulates ~100 bytes per 100ms tick (max burst = 1024).
8838        // A 16 KB chunk needs 16384 tokens, so it should NOT be served quickly.
8839        // We wait 2 seconds — at 1 KB/s we'd have at most 2 KB, still < 16 KB.
8840        let mut got_piece = false;
8841        if let Ok(true) = tokio::time::timeout(Duration::from_secs(2), async {
8842            loop {
8843                match framed_read.next().await {
8844                    Some(Ok(Message::Piece { .. })) => return true,
8845                    Some(Ok(_)) => {}
8846                    _ => return false,
8847                }
8848            }
8849        })
8850        .await
8851        {
8852            got_piece = true;
8853        }
8854
8855        // Piece should NOT have arrived in 2 seconds (would need 16s at 1 KB/s)
8856        assert!(
8857            !got_piece,
8858            "piece should be delayed by rate limiter (1 KB/s for 16 KB chunk)"
8859        );
8860
8861        // Verify actor is still alive
8862        let stats = handle.stats().await.unwrap();
8863        assert_eq!(stats.uploaded, 0); // nothing served yet
8864
8865        handle.shutdown().await.unwrap();
8866    }
8867
8868    #[tokio::test]
8869    async fn unlimited_rate_has_no_effect() {
8870        // Default config (rate = 0) should behave identically to pre-M14
8871        let data = vec![0xAB; 32768];
8872        let meta = make_test_torrent(&data, 16384);
8873        let storage = make_storage(&data, 16384);
8874        let config = test_config();
8875
8876        // Rate limits are 0 (unlimited) by default
8877        assert_eq!(config.upload_rate_limit, 0);
8878        assert_eq!(config.download_rate_limit, 0);
8879
8880        let (atx, amask) = test_alert_channel();
8881        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8882        let handle = TorrentHandle::from_torrent(
8883            meta,
8884            irontide_core::TorrentVersion::V1Only,
8885            None,
8886            dh,
8887            dm,
8888            config,
8889            test_dht_rx(),
8890            test_dht_rx(),
8891            None,
8892            None,
8893            crate::slot_tuner::SlotTuner::disabled(4),
8894            atx,
8895            amask,
8896            None,
8897            None,
8898            test_ban_manager(),
8899            test_ip_filter(),
8900            Arc::new(Vec::new()),
8901            None,
8902            None,
8903            Arc::new(crate::transport::NetworkFactory::tokio()),
8904            None, // M96: hash_pool
8905            Arc::new(crate::stats::SessionCounters::new()),
8906        )
8907        .await
8908        .unwrap();
8909
8910        let stats = handle.stats().await.unwrap();
8911        assert_eq!(stats.state, TorrentState::Downloading);
8912        assert_eq!(stats.pieces_total, 2);
8913
8914        handle.shutdown().await.unwrap();
8915    }
8916
8917    #[tokio::test]
8918    async fn download_rate_limiting_throttles_requests() {
8919        // Test that download_rate_limit prevents sending requests when budget exhausted.
8920        // With 1 KB/s limit and 16 KB chunks, budget is exhausted almost immediately.
8921        let data = vec![0xAB; 32768];
8922        let meta = make_test_torrent(&data, 16384);
8923        let info_hash = meta.info_hash;
8924        let storage = make_storage(&data, 16384);
8925
8926        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8927        let listen_addr = listener.local_addr().unwrap();
8928
8929        let config = TorrentConfig {
8930            listen_port: listen_addr.port(),
8931            download_rate_limit: 1024, // Very low: 1 KB/s
8932            ..test_config()
8933        };
8934
8935        drop(listener);
8936        let (atx, amask) = test_alert_channel();
8937        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8938        let handle = TorrentHandle::from_torrent(
8939            meta,
8940            irontide_core::TorrentVersion::V1Only,
8941            None,
8942            dh,
8943            dm,
8944            config,
8945            test_dht_rx(),
8946            test_dht_rx(),
8947            None,
8948            None,
8949            crate::slot_tuner::SlotTuner::disabled(4),
8950            atx,
8951            amask,
8952            None,
8953            None,
8954            test_ban_manager(),
8955            test_ip_filter(),
8956            Arc::new(Vec::new()),
8957            None,
8958            None,
8959            Arc::new(crate::transport::NetworkFactory::tokio()),
8960            None, // M96: hash_pool
8961            Arc::new(crate::stats::SessionCounters::new()),
8962        )
8963        .await
8964        .unwrap();
8965
8966        tokio::time::sleep(Duration::from_millis(50)).await;
8967
8968        // Connect mock seeder
8969        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
8970        let (reader, writer) = tokio::io::split(stream);
8971        let mut writer = writer;
8972        let mut reader = reader;
8973
8974        let hs = Handshake::new(
8975            info_hash,
8976            Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
8977        );
8978        writer.write_all(&hs.to_bytes()).await.unwrap();
8979        writer.flush().await.unwrap();
8980        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8981        reader.read_exact(&mut hs_buf).await.unwrap();
8982
8983        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
8984        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
8985
8986        // Read ext handshake
8987        let _msg = framed_read.next().await;
8988        let ext_hs = ExtHandshake::new();
8989        let payload = ext_hs.to_bytes().unwrap();
8990        framed_write
8991            .send(Message::Extended { ext_id: 0, payload })
8992            .await
8993            .unwrap();
8994
8995        // Send bitfield saying we have all pieces (act as seeder)
8996        let mut bf = Bitfield::new(2);
8997        bf.set(0);
8998        bf.set(1);
8999        framed_write
9000            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
9001            .await
9002            .unwrap();
9003
9004        // Unchoke the torrent
9005        framed_write.send(Message::Unchoke).await.unwrap();
9006
9007        // Count Request messages received within 500ms.
9008        // With 1 KB/s download limit, the bucket only accumulates ~50 bytes
9009        // per 100ms tick, far less than 16 KB needed for a full chunk request.
9010        let mut requests_received = 0u32;
9011        let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
9012        loop {
9013            match tokio::time::timeout(
9014                deadline.saturating_duration_since(tokio::time::Instant::now()),
9015                framed_read.next(),
9016            )
9017            .await
9018            {
9019                Ok(Some(Ok(Message::Request { .. }))) => {
9020                    requests_received += 1;
9021                }
9022                Ok(Some(Ok(_))) => {}
9023                _ => break,
9024            }
9025        }
9026
9027        let stats = handle.stats().await.unwrap();
9028        assert_eq!(stats.state, TorrentState::Downloading);
9029
9030        // With 1 KB/s download limit and 16 KB chunks, we should see very few
9031        // or no requests within 500ms (budget insufficient for even one chunk)
9032        assert!(
9033            requests_received <= 2,
9034            "with 1 KB/s limit, should get very few requests, got {requests_received}"
9035        );
9036
9037        handle.shutdown().await.unwrap();
9038    }
9039
9040    // ── Smart banning tests (M25) ────────────────────────────────────
9041
9042    #[test]
9043    fn piece_contributor_tracking() {
9044        use std::net::IpAddr;
9045        let mut contributors: HashMap<u32, HashSet<IpAddr>> = HashMap::new();
9046        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9047        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9048
9049        contributors.entry(0).or_default().insert(ip1);
9050        contributors.entry(0).or_default().insert(ip2);
9051        assert_eq!(contributors[&0].len(), 2);
9052        assert!(contributors[&0].contains(&ip1));
9053        assert!(contributors[&0].contains(&ip2));
9054
9055        // Clear on verify
9056        contributors.remove(&0);
9057        assert!(!contributors.contains_key(&0));
9058    }
9059
9060    #[test]
9061    fn parole_enter_on_hash_failure() {
9062        use crate::ban::ParoleState;
9063        use std::net::IpAddr;
9064
9065        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9066        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9067        let contributors = vec![ip1, ip2];
9068
9069        // Simulate entering parole
9070        let parole = ParoleState {
9071            original_contributors: contributors.into_iter().collect(),
9072            parole_peer: None,
9073        };
9074
9075        assert_eq!(parole.original_contributors.len(), 2);
9076        assert!(parole.original_contributors.contains(&ip1));
9077        assert!(parole.original_contributors.contains(&ip2));
9078        assert!(parole.parole_peer.is_none());
9079    }
9080
9081    #[test]
9082    fn parole_success_strikes_originals() {
9083        use crate::ban::{BanConfig, BanManager, ParoleState};
9084        use std::net::IpAddr;
9085
9086        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9087        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
9088        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9089
9090        let mut mgr = BanManager::new(BanConfig {
9091            max_failures: 2,
9092            use_parole: true,
9093        });
9094
9095        let parole = ParoleState {
9096            original_contributors: [ip1, ip2].into_iter().collect(),
9097            parole_peer: Some(parole_ip),
9098        };
9099
9100        // Simulate parole success: strike all originals
9101        for ip in &parole.original_contributors {
9102            mgr.record_strike(*ip);
9103        }
9104
9105        assert_eq!(*mgr.strikes_map().get(&ip1).unwrap(), 1);
9106        assert_eq!(*mgr.strikes_map().get(&ip2).unwrap(), 1);
9107        // Parole peer should not be struck
9108        assert!(!mgr.strikes_map().contains_key(&parole_ip));
9109
9110        // Second strike bans them
9111        for ip in &parole.original_contributors {
9112            mgr.record_strike(*ip);
9113        }
9114        assert!(mgr.is_banned(&ip1));
9115        assert!(mgr.is_banned(&ip2));
9116    }
9117
9118    #[test]
9119    fn parole_failure_strikes_parole_peer() {
9120        use crate::ban::{BanConfig, BanManager, ParoleState};
9121        use std::net::IpAddr;
9122
9123        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
9124        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
9125
9126        let mut mgr = BanManager::new(BanConfig {
9127            max_failures: 2,
9128            use_parole: true,
9129        });
9130
9131        let parole = ParoleState {
9132            original_contributors: [ip1].into_iter().collect(),
9133            parole_peer: Some(parole_ip),
9134        };
9135
9136        // Parole failure: strike the parole peer, not originals
9137        if let Some(pp) = parole.parole_peer {
9138            mgr.record_strike(pp);
9139        }
9140
9141        assert_eq!(*mgr.strikes_map().get(&parole_ip).unwrap(), 1);
9142        assert!(!mgr.strikes_map().contains_key(&ip1));
9143    }
9144
9145    #[tokio::test]
9146    async fn banned_peer_rejected_on_connect() {
9147        let data = vec![0xAB; 32768];
9148        let meta = make_test_torrent(&data, 16384);
9149        let storage = make_storage(&data, 16384);
9150        let config = test_config();
9151        let ban_mgr = test_ban_manager();
9152
9153        // Pre-ban an IP
9154        let banned_ip: std::net::IpAddr = "192.168.1.100".parse().unwrap();
9155        ban_mgr.write().ban(banned_ip);
9156
9157        let (atx, amask) = test_alert_channel();
9158        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9159        let handle = TorrentHandle::from_torrent(
9160            meta,
9161            irontide_core::TorrentVersion::V1Only,
9162            None,
9163            dh,
9164            dm,
9165            config,
9166            test_dht_rx(),
9167            test_dht_rx(),
9168            None,
9169            None,
9170            crate::slot_tuner::SlotTuner::disabled(4),
9171            atx,
9172            amask,
9173            None,
9174            None,
9175            Arc::clone(&ban_mgr),
9176            test_ip_filter(),
9177            Arc::new(Vec::new()),
9178            None,
9179            None,
9180            Arc::new(crate::transport::NetworkFactory::tokio()),
9181            None, // M96: hash_pool
9182            Arc::new(crate::stats::SessionCounters::new()),
9183        )
9184        .await
9185        .unwrap();
9186
9187        // Add the banned peer — it should be filtered out
9188        handle
9189            .add_peers(
9190                vec![
9191                    SocketAddr::new(banned_ip, 6881),
9192                    "10.0.0.1:6881".parse().unwrap(),
9193                ],
9194                PeerSource::Tracker,
9195            )
9196            .await
9197            .unwrap();
9198
9199        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9200        let stats = handle.stats().await.unwrap();
9201        // Only the non-banned peer should be in available pool (and may have connected)
9202        // The banned one should never appear
9203        assert!(
9204            stats.peers_available + stats.peers_connected <= 1,
9205            "banned peer should not be added: available={}, connected={}",
9206            stats.peers_available,
9207            stats.peers_connected
9208        );
9209
9210        handle.shutdown().await.unwrap();
9211    }
9212
9213    #[test]
9214    fn banned_peer_filtered_from_available() {
9215        use crate::ban::{BanConfig, BanManager};
9216        use std::net::IpAddr;
9217
9218        let banned_ip: IpAddr = "192.168.1.200".parse().unwrap();
9219        let ok_ip: IpAddr = "10.0.0.1".parse().unwrap();
9220
9221        let mgr = BanManager::new(BanConfig::default());
9222        // Not banned yet — both should pass
9223        assert!(!mgr.is_banned(&banned_ip));
9224        assert!(!mgr.is_banned(&ok_ip));
9225
9226        let mut mgr = BanManager::new(BanConfig::default());
9227        mgr.ban(banned_ip);
9228
9229        // Now banned_ip is filtered, ok_ip is not
9230        assert!(mgr.is_banned(&banned_ip));
9231        assert!(!mgr.is_banned(&ok_ip));
9232    }
9233
9234    // ---- M27: Parallel hashing tests ----
9235
9236    #[test]
9237    fn hashing_threads_config_default() {
9238        let s = irontide_settings::Settings::default();
9239        let expected = {
9240            let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
9241            (cores / 4).clamp(2, 8)
9242        };
9243        assert_eq!(s.hashing_threads, expected);
9244        let tc = TorrentConfig::default();
9245        assert_eq!(tc.hashing_threads, expected);
9246    }
9247
9248    #[tokio::test]
9249    async fn checking_state_and_progress_alerts() {
9250        use crate::alert::AlertKind;
9251
9252        let data = vec![0xEEu8; 65536]; // 4 pieces of 16384
9253        let meta = make_test_torrent(&data, 16384);
9254        let storage = make_seeded_storage(&data, 16384);
9255        let config = test_config();
9256
9257        let (atx, amask) = test_alert_channel();
9258        let mut rx = atx.subscribe();
9259        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9260        let handle = TorrentHandle::from_torrent(
9261            meta,
9262            irontide_core::TorrentVersion::V1Only,
9263            None,
9264            dh,
9265            dm,
9266            config,
9267            test_dht_rx(),
9268            test_dht_rx(),
9269            None,
9270            None,
9271            crate::slot_tuner::SlotTuner::disabled(4),
9272            atx,
9273            amask,
9274            None,
9275            None,
9276            test_ban_manager(),
9277            test_ip_filter(),
9278            Arc::new(Vec::new()),
9279            None,
9280            None,
9281            Arc::new(crate::transport::NetworkFactory::tokio()),
9282            None, // M96: hash_pool
9283            Arc::new(crate::stats::SessionCounters::new()),
9284        )
9285        .await
9286        .unwrap();
9287
9288        // Collect alerts for up to 2 seconds
9289        let mut saw_checking = false;
9290        let mut progress_values: Vec<f32> = Vec::new();
9291        let mut saw_checked = false;
9292        let mut checked_have = 0u32;
9293        let mut checked_total = 0u32;
9294
9295        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9296        while tokio::time::Instant::now() < deadline {
9297            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9298                Ok(Ok(alert)) => match alert.kind {
9299                    AlertKind::StateChanged {
9300                        new_state: TorrentState::Checking,
9301                        ..
9302                    } => {
9303                        saw_checking = true;
9304                    }
9305                    AlertKind::CheckingProgress { progress, .. } => {
9306                        progress_values.push(progress);
9307                    }
9308                    AlertKind::TorrentChecked {
9309                        pieces_have,
9310                        pieces_total,
9311                        ..
9312                    } => {
9313                        saw_checked = true;
9314                        checked_have = pieces_have;
9315                        checked_total = pieces_total;
9316                        break;
9317                    }
9318                    _ => {}
9319                },
9320                _ => break,
9321            }
9322        }
9323
9324        assert!(saw_checking, "should have seen StateChanged → Checking");
9325        assert!(
9326            !progress_values.is_empty(),
9327            "should have seen CheckingProgress alerts"
9328        );
9329        // Progress should be monotonically increasing
9330        for w in progress_values.windows(2) {
9331            assert!(
9332                w[1] >= w[0],
9333                "progress should be monotonically increasing: {} < {}",
9334                w[0],
9335                w[1]
9336            );
9337        }
9338        assert!(saw_checked, "should have seen TorrentChecked");
9339        assert_eq!(checked_have, 4);
9340        assert_eq!(checked_total, 4);
9341
9342        // Final state should be Seeding (all pieces valid)
9343        tokio::time::sleep(Duration::from_millis(50)).await;
9344        let stats = handle.stats().await.unwrap();
9345        assert_eq!(stats.state, TorrentState::Seeding);
9346
9347        handle.shutdown().await.unwrap();
9348    }
9349
9350    #[tokio::test]
9351    #[allow(clippy::float_cmp, reason = "exact sentinel value comparison (0.0)")]
9352    async fn checking_progress_in_stats() {
9353        // When not in Checking state, checking_progress should be 0.0
9354        let data = vec![0xAB; 32768];
9355        let meta = make_test_torrent(&data, 16384);
9356        let storage = make_storage(&data, 16384);
9357        let config = test_config();
9358
9359        let (atx, amask) = test_alert_channel();
9360        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9361        let handle = TorrentHandle::from_torrent(
9362            meta,
9363            irontide_core::TorrentVersion::V1Only,
9364            None,
9365            dh,
9366            dm,
9367            config,
9368            test_dht_rx(),
9369            test_dht_rx(),
9370            None,
9371            None,
9372            crate::slot_tuner::SlotTuner::disabled(4),
9373            atx,
9374            amask,
9375            None,
9376            None,
9377            test_ban_manager(),
9378            test_ip_filter(),
9379            Arc::new(Vec::new()),
9380            None,
9381            None,
9382            Arc::new(crate::transport::NetworkFactory::tokio()),
9383            None, // M96: hash_pool
9384            Arc::new(crate::stats::SessionCounters::new()),
9385        )
9386        .await
9387        .unwrap();
9388
9389        // Give actor time to finish checking (no valid pieces → Downloading)
9390        tokio::time::sleep(Duration::from_millis(100)).await;
9391
9392        let stats = handle.stats().await.unwrap();
9393        assert_eq!(stats.state, TorrentState::Downloading);
9394        assert_eq!(
9395            stats.checking_progress, 0.0,
9396            "checking_progress should be 0.0 when not checking"
9397        );
9398
9399        handle.shutdown().await.unwrap();
9400    }
9401
9402    #[tokio::test]
9403    async fn verify_pieces_partial_data() {
9404        use crate::alert::AlertKind;
9405
9406        // 4 pieces, only first 2 have valid data
9407        let data = vec![0xCCu8; 65536]; // 4 pieces × 16384
9408        let meta = make_test_torrent(&data, 16384);
9409
9410        // Create storage and only write valid data for pieces 0 and 1
9411        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9412        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
9413        for p in 0..2u32 {
9414            let offset = lengths.piece_offset(p) as usize;
9415            let size = lengths.piece_size(p) as usize;
9416            storage
9417                .write_chunk(p, 0, &data[offset..offset + size])
9418                .unwrap();
9419        }
9420        // Pieces 2 and 3 have no data (zeros) — won't match hash
9421
9422        let config = test_config();
9423        let (atx, amask) = test_alert_channel();
9424        let mut rx = atx.subscribe();
9425        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9426        let handle = TorrentHandle::from_torrent(
9427            meta,
9428            irontide_core::TorrentVersion::V1Only,
9429            None,
9430            dh,
9431            dm,
9432            config,
9433            test_dht_rx(),
9434            test_dht_rx(),
9435            None,
9436            None,
9437            crate::slot_tuner::SlotTuner::disabled(4),
9438            atx,
9439            amask,
9440            None,
9441            None,
9442            test_ban_manager(),
9443            test_ip_filter(),
9444            Arc::new(Vec::new()),
9445            None,
9446            None,
9447            Arc::new(crate::transport::NetworkFactory::tokio()),
9448            None, // M96: hash_pool
9449            Arc::new(crate::stats::SessionCounters::new()),
9450        )
9451        .await
9452        .unwrap();
9453
9454        // Wait for TorrentChecked alert
9455        let mut checked_have = 0u32;
9456        let mut checked_total = 0u32;
9457        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
9458        while tokio::time::Instant::now() < deadline {
9459            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
9460                Ok(Ok(alert)) => {
9461                    if let AlertKind::TorrentChecked {
9462                        pieces_have,
9463                        pieces_total,
9464                        ..
9465                    } = alert.kind
9466                    {
9467                        checked_have = pieces_have;
9468                        checked_total = pieces_total;
9469                        break;
9470                    }
9471                }
9472                _ => break,
9473            }
9474        }
9475
9476        assert_eq!(checked_have, 2, "only 2 pieces should be valid");
9477        assert_eq!(checked_total, 4);
9478
9479        // Final state should be Downloading (partial)
9480        tokio::time::sleep(Duration::from_millis(50)).await;
9481        let stats = handle.stats().await.unwrap();
9482        assert_eq!(stats.state, TorrentState::Downloading);
9483        assert_eq!(stats.pieces_have, 2);
9484        assert_eq!(stats.pieces_total, 4);
9485
9486        handle.shutdown().await.unwrap();
9487    }
9488
9489    // ---- M29: IP filter integration tests ----
9490
9491    #[tokio::test]
9492    async fn ip_filter_blocks_peers_in_handle_add_peers() {
9493        let data = vec![0xCD; 32768];
9494        let meta = make_test_torrent(&data, 16384);
9495        let storage = make_storage(&data, 16384);
9496        let config = test_config();
9497
9498        // Create an IP filter that blocks 203.0.113.0/24 (TEST-NET-3, public range)
9499        let ip_filter = {
9500            let mut f = crate::ip_filter::IpFilter::new();
9501            f.add_rule(
9502                "203.0.113.0".parse().unwrap(),
9503                "203.0.113.255".parse().unwrap(),
9504                1,
9505            );
9506            Arc::new(parking_lot::RwLock::new(f))
9507        };
9508
9509        let (atx, amask) = test_alert_channel();
9510        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9511        let handle = TorrentHandle::from_torrent(
9512            meta,
9513            irontide_core::TorrentVersion::V1Only,
9514            None,
9515            dh,
9516            dm,
9517            config,
9518            test_dht_rx(),
9519            test_dht_rx(),
9520            None,
9521            None,
9522            crate::slot_tuner::SlotTuner::disabled(4),
9523            atx,
9524            amask,
9525            None,
9526            None,
9527            test_ban_manager(),
9528            Arc::clone(&ip_filter),
9529            Arc::new(Vec::new()),
9530            None,
9531            None,
9532            Arc::new(crate::transport::NetworkFactory::tokio()),
9533            None, // M96: hash_pool
9534            Arc::new(crate::stats::SessionCounters::new()),
9535        )
9536        .await
9537        .unwrap();
9538
9539        // Add peers: one blocked (public IP in TEST-NET-3), one allowed (different public IP)
9540        let blocked_addr: SocketAddr = "203.0.113.42:6881".parse().unwrap();
9541        let allowed_addr: SocketAddr = "198.51.100.1:6881".parse().unwrap();
9542        handle
9543            .add_peers(vec![blocked_addr, allowed_addr], PeerSource::Tracker)
9544            .await
9545            .unwrap();
9546
9547        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9548        let stats = handle.stats().await.unwrap();
9549        // Only the allowed peer should be in the pool
9550        assert!(
9551            stats.peers_available + stats.peers_connected <= 1,
9552            "blocked peer should not be added: available={}, connected={}",
9553            stats.peers_available,
9554            stats.peers_connected
9555        );
9556
9557        handle.shutdown().await.unwrap();
9558    }
9559
9560    #[tokio::test]
9561    async fn set_ip_filter_replaces_filter_and_blocks_new_ip() {
9562        // Test that updating the shared IP filter takes effect for new peer additions.
9563        // Use public IPs (TEST-NET ranges) since local networks are always exempt.
9564        let data = vec![0xCD; 32768];
9565        let meta = make_test_torrent(&data, 16384);
9566        let storage = make_storage(&data, 16384);
9567        let config = test_config();
9568
9569        // Start with empty filter (everything allowed)
9570        let ip_filter: irontide_session_types::SharedIpFilter =
9571            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
9572
9573        let (atx, amask) = test_alert_channel();
9574        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9575        let handle = TorrentHandle::from_torrent(
9576            meta,
9577            irontide_core::TorrentVersion::V1Only,
9578            None,
9579            dh,
9580            dm,
9581            config,
9582            test_dht_rx(),
9583            test_dht_rx(),
9584            None,
9585            None,
9586            crate::slot_tuner::SlotTuner::disabled(4),
9587            atx,
9588            amask,
9589            None,
9590            None,
9591            test_ban_manager(),
9592            Arc::clone(&ip_filter),
9593            Arc::new(Vec::new()),
9594            None,
9595            None,
9596            Arc::new(crate::transport::NetworkFactory::tokio()),
9597            None, // M96: hash_pool
9598            Arc::new(crate::stats::SessionCounters::new()),
9599        )
9600        .await
9601        .unwrap();
9602
9603        // Initially, peers are allowed by the IP filter.
9604        // Use a local listener so the connection succeeds and the peer stays known.
9605        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
9606        let local_addr = listener.local_addr().unwrap();
9607        handle
9608            .add_peers(vec![local_addr], PeerSource::Tracker)
9609            .await
9610            .unwrap();
9611        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9612        let stats = handle.stats().await.unwrap();
9613        assert!(
9614            stats.peers_available + stats.peers_connected >= 1,
9615            "peer should be allowed initially"
9616        );
9617        handle.shutdown().await.unwrap();
9618
9619        // Now update the shared filter to block that IP range
9620        {
9621            let mut f = ip_filter.write();
9622            f.add_rule(
9623                "198.51.100.0".parse().unwrap(),
9624                "198.51.100.255".parse().unwrap(),
9625                1,
9626            );
9627        }
9628
9629        // Verify the filter is updated (public IP, so is_blocked applies)
9630        assert!(ip_filter.read().is_blocked("198.51.100.1".parse().unwrap()));
9631        // Verify a different public IP is still allowed
9632        assert!(!ip_filter.read().is_blocked("203.0.113.1".parse().unwrap()));
9633    }
9634
9635    #[test]
9636    fn relocate_files_moves_and_cleans_up() {
9637        let tmp = std::env::temp_dir().join(format!("torrent_relocate_{}", std::process::id()));
9638        let src = tmp.join("src");
9639        let dst = tmp.join("dst");
9640
9641        // Create source files mimicking multi-file torrent layout:
9642        // TorrentName/subdir/file1.txt
9643        // TorrentName/file2.txt
9644        let subdir = src.join("TorrentName").join("subdir");
9645        std::fs::create_dir_all(&subdir).unwrap();
9646        std::fs::write(subdir.join("file1.txt"), b"hello").unwrap();
9647        std::fs::write(src.join("TorrentName").join("file2.txt"), b"world").unwrap();
9648
9649        let file_paths = vec![
9650            std::path::PathBuf::from("TorrentName/subdir/file1.txt"),
9651            std::path::PathBuf::from("TorrentName/file2.txt"),
9652        ];
9653
9654        relocate_files(&src, &dst, &file_paths).unwrap();
9655
9656        // Destination should have both files
9657        assert_eq!(
9658            std::fs::read_to_string(dst.join("TorrentName/subdir/file1.txt")).unwrap(),
9659            "hello"
9660        );
9661        assert_eq!(
9662            std::fs::read_to_string(dst.join("TorrentName/file2.txt")).unwrap(),
9663            "world"
9664        );
9665
9666        // Source directory should be cleaned up (empty dirs removed)
9667        assert!(!src.join("TorrentName").join("subdir").exists());
9668        assert!(!src.join("TorrentName").exists());
9669
9670        // Cleanup
9671        let _ = std::fs::remove_dir_all(&tmp);
9672    }
9673
9674    #[test]
9675    fn relocate_files_skips_missing() {
9676        let tmp =
9677            std::env::temp_dir().join(format!("torrent_relocate_skip_{}", std::process::id()));
9678        let src = tmp.join("src");
9679        let dst = tmp.join("dst");
9680        std::fs::create_dir_all(&src).unwrap();
9681
9682        // File doesn't exist — should be skipped without error
9683        let file_paths = vec![std::path::PathBuf::from("nonexistent.txt")];
9684        relocate_files(&src, &dst, &file_paths).unwrap();
9685
9686        assert!(!dst.join("nonexistent.txt").exists());
9687
9688        let _ = std::fs::remove_dir_all(&tmp);
9689    }
9690
9691    // ---- Test: force_recheck transitions through Checking state ----
9692
9693    #[tokio::test]
9694    async fn force_recheck_transitions_to_checking() {
9695        let data = vec![0xDDu8; 32768]; // 2 pieces
9696        let meta = make_test_torrent(&data, 16384);
9697        let storage = make_seeded_storage(&data, 16384);
9698        let config = test_config();
9699
9700        let (atx, amask) = test_alert_channel();
9701        let mut arx = atx.subscribe();
9702        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9703        let handle = TorrentHandle::from_torrent(
9704            meta,
9705            irontide_core::TorrentVersion::V1Only,
9706            None,
9707            dh,
9708            dm,
9709            config,
9710            test_dht_rx(),
9711            test_dht_rx(),
9712            None,
9713            None,
9714            crate::slot_tuner::SlotTuner::disabled(4),
9715            atx,
9716            amask,
9717            None,
9718            None,
9719            test_ban_manager(),
9720            test_ip_filter(),
9721            Arc::new(Vec::new()),
9722            None,
9723            None,
9724            Arc::new(crate::transport::NetworkFactory::tokio()),
9725            None, // M96: hash_pool
9726            Arc::new(crate::stats::SessionCounters::new()),
9727        )
9728        .await
9729        .unwrap();
9730
9731        // Wait for initial verification to complete (should become Seeding)
9732        tokio::time::sleep(Duration::from_millis(100)).await;
9733        let stats = handle.stats().await.unwrap();
9734        assert_eq!(stats.state, TorrentState::Seeding, "should start as seeder");
9735
9736        // Drain any existing alerts
9737        while arx.try_recv().is_ok() {}
9738
9739        // Force recheck
9740        handle.force_recheck().await.unwrap();
9741
9742        // After force_recheck returns, look for a StateChanged alert that
9743        // went through Checking (the transition_state fires it)
9744        let mut saw_checking = false;
9745        while let Ok(alert) = arx.try_recv() {
9746            if let crate::alert::AlertKind::StateChanged { new_state, .. } = alert.kind
9747                && new_state == TorrentState::Checking
9748            {
9749                saw_checking = true;
9750            }
9751        }
9752        assert!(
9753            saw_checking,
9754            "should have transitioned through Checking state"
9755        );
9756
9757        handle.shutdown().await.unwrap();
9758    }
9759
9760    // ---- Test: force_recheck completes with correct state ----
9761
9762    #[tokio::test]
9763    async fn force_recheck_completes() {
9764        let data = vec![0xEEu8; 32768]; // 2 pieces
9765        let meta = make_test_torrent(&data, 16384);
9766        let storage = make_seeded_storage(&data, 16384);
9767        let config = test_config();
9768
9769        let (atx, amask) = test_alert_channel();
9770        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9771        let handle = TorrentHandle::from_torrent(
9772            meta,
9773            irontide_core::TorrentVersion::V1Only,
9774            None,
9775            dh,
9776            dm,
9777            config,
9778            test_dht_rx(),
9779            test_dht_rx(),
9780            None,
9781            None,
9782            crate::slot_tuner::SlotTuner::disabled(4),
9783            atx,
9784            amask,
9785            None,
9786            None,
9787            test_ban_manager(),
9788            test_ip_filter(),
9789            Arc::new(Vec::new()),
9790            None,
9791            None,
9792            Arc::new(crate::transport::NetworkFactory::tokio()),
9793            None, // M96: hash_pool
9794            Arc::new(crate::stats::SessionCounters::new()),
9795        )
9796        .await
9797        .unwrap();
9798
9799        // Wait for initial verification
9800        tokio::time::sleep(Duration::from_millis(100)).await;
9801        let stats = handle.stats().await.unwrap();
9802        assert_eq!(stats.state, TorrentState::Seeding);
9803        assert_eq!(stats.pieces_have, 2);
9804
9805        // Force recheck — should re-verify all pieces and return to Seeding
9806        handle.force_recheck().await.unwrap();
9807
9808        let stats = handle.stats().await.unwrap();
9809        assert_eq!(
9810            stats.state,
9811            TorrentState::Seeding,
9812            "should return to Seeding after recheck"
9813        );
9814        assert_eq!(stats.pieces_have, 2, "all pieces should still be verified");
9815
9816        handle.shutdown().await.unwrap();
9817    }
9818
9819    // ---- Test: rename_file succeeds with valid index ----
9820
9821    #[tokio::test]
9822    async fn rename_file_succeeds() {
9823        // Create a real file on disk that we can rename
9824        let tmp = std::env::temp_dir().join(format!("torrent_rename_{}", std::process::id()));
9825        std::fs::create_dir_all(&tmp).unwrap();
9826
9827        let data = vec![0xFFu8; 16384]; // 1 piece
9828        let meta = make_test_torrent(&data, 16384);
9829        let storage = make_seeded_storage(&data, 16384);
9830
9831        // The single-file torrent has name "test", so file path is "test"
9832        // Create the actual file on disk at download_dir/test
9833        std::fs::write(tmp.join("test"), &data).unwrap();
9834
9835        let mut config = test_config();
9836        config.download_dir = tmp.clone();
9837
9838        let (atx, amask) = test_alert_channel();
9839        let mut arx = atx.subscribe();
9840        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9841        let handle = TorrentHandle::from_torrent(
9842            meta,
9843            irontide_core::TorrentVersion::V1Only,
9844            None,
9845            dh,
9846            dm,
9847            config,
9848            test_dht_rx(),
9849            test_dht_rx(),
9850            None,
9851            None,
9852            crate::slot_tuner::SlotTuner::disabled(4),
9853            atx,
9854            amask,
9855            None,
9856            None,
9857            test_ban_manager(),
9858            test_ip_filter(),
9859            Arc::new(Vec::new()),
9860            None,
9861            None,
9862            Arc::new(crate::transport::NetworkFactory::tokio()),
9863            None, // M96: hash_pool
9864            Arc::new(crate::stats::SessionCounters::new()),
9865        )
9866        .await
9867        .unwrap();
9868
9869        // Wait for initial verification
9870        tokio::time::sleep(Duration::from_millis(100)).await;
9871
9872        // Drain existing alerts
9873        while arx.try_recv().is_ok() {}
9874
9875        // Rename file 0 to "test_renamed"
9876        handle.rename_file(0, "test_renamed".into()).await.unwrap();
9877
9878        // Check that the old file is gone and new file exists
9879        assert!(!tmp.join("test").exists(), "old file should be removed");
9880        assert!(tmp.join("test_renamed").exists(), "new file should exist");
9881
9882        // Check that FileRenamed alert was fired
9883        let mut saw_renamed = false;
9884        while let Ok(alert) = arx.try_recv() {
9885            if let AlertKind::FileRenamed { index, .. } = alert.kind {
9886                assert_eq!(index, 0);
9887                saw_renamed = true;
9888            }
9889        }
9890        assert!(saw_renamed, "should have received FileRenamed alert");
9891
9892        handle.shutdown().await.unwrap();
9893        let _ = std::fs::remove_dir_all(&tmp);
9894    }
9895
9896    // ---- Test: rename_file with invalid index returns error ----
9897
9898    #[tokio::test]
9899    async fn rename_file_invalid_index_errors() {
9900        let data = vec![0xCCu8; 16384]; // 1 piece, single-file torrent
9901        let meta = make_test_torrent(&data, 16384);
9902        let storage = make_seeded_storage(&data, 16384);
9903        let config = test_config();
9904
9905        let (atx, amask) = test_alert_channel();
9906        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9907        let handle = TorrentHandle::from_torrent(
9908            meta,
9909            irontide_core::TorrentVersion::V1Only,
9910            None,
9911            dh,
9912            dm,
9913            config,
9914            test_dht_rx(),
9915            test_dht_rx(),
9916            None,
9917            None,
9918            crate::slot_tuner::SlotTuner::disabled(4),
9919            atx,
9920            amask,
9921            None,
9922            None,
9923            test_ban_manager(),
9924            test_ip_filter(),
9925            Arc::new(Vec::new()),
9926            None,
9927            None,
9928            Arc::new(crate::transport::NetworkFactory::tokio()),
9929            None, // M96: hash_pool
9930            Arc::new(crate::stats::SessionCounters::new()),
9931        )
9932        .await
9933        .unwrap();
9934
9935        // Wait for initial verification
9936        tokio::time::sleep(Duration::from_millis(100)).await;
9937
9938        // Try to rename file index 99 (out of range)
9939        let result = handle.rename_file(99, "bad".into()).await;
9940        assert!(result.is_err(), "should fail for out-of-range file index");
9941
9942        handle.shutdown().await.unwrap();
9943    }
9944
9945    // ---- Test: FileCompleted alert fires when all pieces of a file are verified ----
9946
9947    #[tokio::test]
9948    async fn file_completed_alert_fires() {
9949        let data = vec![0xBBu8; 32768]; // 2 pieces
9950        let meta = make_test_torrent(&data, 16384);
9951        let storage = make_seeded_storage(&data, 16384);
9952        let config = test_config();
9953
9954        let (atx, amask) = test_alert_channel();
9955        let mut arx = atx.subscribe();
9956        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9957        let handle = TorrentHandle::from_torrent(
9958            meta,
9959            irontide_core::TorrentVersion::V1Only,
9960            None,
9961            dh,
9962            dm,
9963            config,
9964            test_dht_rx(),
9965            test_dht_rx(),
9966            None,
9967            None,
9968            crate::slot_tuner::SlotTuner::disabled(4),
9969            atx,
9970            amask,
9971            None,
9972            None,
9973            test_ban_manager(),
9974            test_ip_filter(),
9975            Arc::new(Vec::new()),
9976            None,
9977            None,
9978            Arc::new(crate::transport::NetworkFactory::tokio()),
9979            None, // M96: hash_pool
9980            Arc::new(crate::stats::SessionCounters::new()),
9981        )
9982        .await
9983        .unwrap();
9984
9985        // Wait for initial verification (seeded storage => all pieces verify)
9986        tokio::time::sleep(Duration::from_millis(200)).await;
9987
9988        // Should have received FileCompleted alert for the single file
9989        let mut saw_file_completed = false;
9990        while let Ok(alert) = arx.try_recv() {
9991            if let AlertKind::FileCompleted { file_index, .. } = alert.kind {
9992                assert_eq!(file_index, 0, "should be file index 0");
9993                saw_file_completed = true;
9994            }
9995        }
9996        assert!(
9997            saw_file_completed,
9998            "should have received FileCompleted alert"
9999        );
10000
10001        handle.shutdown().await.unwrap();
10002    }
10003
10004    // ---- Test: MetadataFailed alert fires (unit test on AlertKind) ----
10005
10006    #[test]
10007    fn metadata_failed_alert_fires() {
10008        // Test that MetadataFailed alert has the correct category
10009        let info_hash = Id20::from([0u8; 20]);
10010        let alert = crate::alert::Alert::new(AlertKind::MetadataFailed { info_hash });
10011        assert!(
10012            alert
10013                .category()
10014                .contains(crate::alert::AlertCategory::STATUS),
10015            "MetadataFailed should have STATUS category"
10016        );
10017        assert!(
10018            alert
10019                .category()
10020                .contains(crate::alert::AlertCategory::ERROR),
10021            "MetadataFailed should have ERROR category"
10022        );
10023
10024        // Verify it can be posted through the alert system
10025        let (tx, mut rx) = broadcast::channel(16);
10026        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
10027        post_alert(&tx, &mask, AlertKind::MetadataFailed { info_hash });
10028        let received = rx.try_recv().expect("should receive MetadataFailed alert");
10029        assert!(matches!(received.kind, AlertKind::MetadataFailed { .. }));
10030    }
10031
10032    // ---- Test: set_max_connections persists ----
10033
10034    #[tokio::test]
10035    async fn set_max_connections_persists() {
10036        let data = vec![0xAB; 32768];
10037        let meta = make_test_torrent(&data, 16384);
10038        let storage = make_storage(&data, 16384);
10039        let config = test_config();
10040
10041        let (atx, amask) = test_alert_channel();
10042        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10043        let handle = TorrentHandle::from_torrent(
10044            meta,
10045            irontide_core::TorrentVersion::V1Only,
10046            None,
10047            dh,
10048            dm,
10049            config,
10050            test_dht_rx(),
10051            test_dht_rx(),
10052            None,
10053            None,
10054            crate::slot_tuner::SlotTuner::disabled(4),
10055            atx,
10056            amask,
10057            None,
10058            None,
10059            test_ban_manager(),
10060            test_ip_filter(),
10061            Arc::new(Vec::new()),
10062            None,
10063            None,
10064            Arc::new(crate::transport::NetworkFactory::tokio()),
10065            None, // M96: hash_pool
10066            Arc::new(crate::stats::SessionCounters::new()),
10067        )
10068        .await
10069        .unwrap();
10070
10071        // Set max_connections to 10
10072        handle.set_max_connections(10).await.unwrap();
10073        let val = handle.max_connections().await.unwrap();
10074        assert_eq!(val, 10);
10075
10076        // Update to a different value
10077        handle.set_max_connections(25).await.unwrap();
10078        let val = handle.max_connections().await.unwrap();
10079        assert_eq!(val, 25);
10080
10081        // Verify stats reflect the override
10082        let stats = handle.stats().await.unwrap();
10083        assert_eq!(stats.connections_limit, 25);
10084
10085        handle.shutdown().await.unwrap();
10086    }
10087
10088    // ---- Test: max_connections default is 0 (use config.max_peers) ----
10089
10090    #[tokio::test]
10091    async fn max_connections_default() {
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        let expected_default = config.max_peers;
10097
10098        let (atx, amask) = test_alert_channel();
10099        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10100        let handle = TorrentHandle::from_torrent(
10101            meta,
10102            irontide_core::TorrentVersion::V1Only,
10103            None,
10104            dh,
10105            dm,
10106            config,
10107            test_dht_rx(),
10108            test_dht_rx(),
10109            None,
10110            None,
10111            crate::slot_tuner::SlotTuner::disabled(4),
10112            atx,
10113            amask,
10114            None,
10115            None,
10116            test_ban_manager(),
10117            test_ip_filter(),
10118            Arc::new(Vec::new()),
10119            None,
10120            None,
10121            Arc::new(crate::transport::NetworkFactory::tokio()),
10122            None, // M96: hash_pool
10123            Arc::new(crate::stats::SessionCounters::new()),
10124        )
10125        .await
10126        .unwrap();
10127
10128        // Default max_connections should be 0
10129        let val = handle.max_connections().await.unwrap();
10130        assert_eq!(val, 0);
10131
10132        // Stats should show config.max_peers as the effective limit
10133        let stats = handle.stats().await.unwrap();
10134        assert_eq!(stats.connections_limit, expected_default);
10135
10136        handle.shutdown().await.unwrap();
10137    }
10138
10139    // ---- Test: set_max_uploads round trip ----
10140
10141    #[tokio::test]
10142    async fn set_max_uploads_round_trip() {
10143        let data = vec![0xAB; 32768];
10144        let meta = make_test_torrent(&data, 16384);
10145        let storage = make_storage(&data, 16384);
10146        let config = test_config();
10147
10148        let (atx, amask) = test_alert_channel();
10149        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10150        let handle = TorrentHandle::from_torrent(
10151            meta,
10152            irontide_core::TorrentVersion::V1Only,
10153            None,
10154            dh,
10155            dm,
10156            config,
10157            test_dht_rx(),
10158            test_dht_rx(),
10159            None,
10160            None,
10161            crate::slot_tuner::SlotTuner::disabled(4),
10162            atx,
10163            amask,
10164            None,
10165            None,
10166            test_ban_manager(),
10167            test_ip_filter(),
10168            Arc::new(Vec::new()),
10169            None,
10170            None,
10171            Arc::new(crate::transport::NetworkFactory::tokio()),
10172            None, // M96: hash_pool
10173            Arc::new(crate::stats::SessionCounters::new()),
10174        )
10175        .await
10176        .unwrap();
10177
10178        // Set max_uploads to 8
10179        handle.set_max_uploads(8).await.unwrap();
10180        let val = handle.max_uploads().await.unwrap();
10181        assert_eq!(val, 8);
10182
10183        // Verify stats uploads_limit reflects the new value
10184        let stats = handle.stats().await.unwrap();
10185        assert_eq!(stats.uploads_limit, 8);
10186
10187        handle.shutdown().await.unwrap();
10188    }
10189
10190    // ---- Test: ExternalIpDetected alert fires ----
10191
10192    #[tokio::test]
10193    async fn external_ip_detected_alert() {
10194        let data = vec![0xAB; 32768];
10195        let meta = make_test_torrent(&data, 16384);
10196        let storage = make_storage(&data, 16384);
10197        let config = test_config();
10198
10199        let (atx, amask) = test_alert_channel();
10200        let mut arx = atx.subscribe();
10201        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10202        let handle = TorrentHandle::from_torrent(
10203            meta,
10204            irontide_core::TorrentVersion::V1Only,
10205            None,
10206            dh,
10207            dm,
10208            config,
10209            test_dht_rx(),
10210            test_dht_rx(),
10211            None,
10212            None,
10213            crate::slot_tuner::SlotTuner::disabled(4),
10214            atx,
10215            amask,
10216            None,
10217            None,
10218            test_ban_manager(),
10219            test_ip_filter(),
10220            Arc::new(Vec::new()),
10221            None,
10222            None,
10223            Arc::new(crate::transport::NetworkFactory::tokio()),
10224            None, // M96: hash_pool
10225            Arc::new(crate::stats::SessionCounters::new()),
10226        )
10227        .await
10228        .unwrap();
10229
10230        // Drain any initial alerts
10231        while arx.try_recv().is_ok() {}
10232
10233        // Send UpdateExternalIp command
10234        let test_ip: std::net::IpAddr = "203.0.113.42".parse().unwrap();
10235        handle
10236            .cmd_tx
10237            .send(TorrentCommand::UpdateExternalIp { ip: test_ip })
10238            .await
10239            .unwrap();
10240
10241        // Wait for the actor to process
10242        tokio::time::sleep(Duration::from_millis(50)).await;
10243
10244        // Check for ExternalIpDetected alert
10245        let mut saw_alert = false;
10246        while let Ok(alert) = arx.try_recv() {
10247            if let AlertKind::ExternalIpDetected { ip } = alert.kind {
10248                assert_eq!(ip, test_ip);
10249                saw_alert = true;
10250            }
10251        }
10252        assert!(saw_alert, "should have received ExternalIpDetected alert");
10253
10254        handle.shutdown().await.unwrap();
10255    }
10256
10257    // ---- Test: get_peer_info returns connected peers ----
10258
10259    #[tokio::test]
10260    async fn get_peer_info_returns_connected_peers() {
10261        let data = vec![0xAB; 65536]; // 64 KiB
10262        let meta = make_test_torrent(&data, 16384); // 4 pieces
10263        let storage = make_storage(&data, 16384);
10264        let config = test_config();
10265
10266        let (atx, amask) = test_alert_channel();
10267        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10268        let handle = TorrentHandle::from_torrent(
10269            meta.clone(),
10270            irontide_core::TorrentVersion::V1Only,
10271            None,
10272            dh,
10273            dm,
10274            config,
10275            test_dht_rx(),
10276            test_dht_rx(),
10277            None,
10278            None,
10279            crate::slot_tuner::SlotTuner::disabled(4),
10280            atx,
10281            amask,
10282            None,
10283            None,
10284            test_ban_manager(),
10285            test_ip_filter(),
10286            Arc::new(Vec::new()),
10287            None,
10288            None,
10289            Arc::new(crate::transport::NetworkFactory::tokio()),
10290            None, // M96: hash_pool
10291            Arc::new(crate::stats::SessionCounters::new()),
10292        )
10293        .await
10294        .unwrap();
10295
10296        // Set up a fake peer via TCP handshake
10297        let stats = handle.stats().await.unwrap();
10298        let listen_port = stats.peers_connected; // Initially 0
10299
10300        // Add a peer to the available pool and let the actor connect
10301        let peer_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
10302        let peer_addr = peer_listener.local_addr().unwrap();
10303
10304        handle
10305            .add_peers(vec![peer_addr], PeerSource::Tracker)
10306            .await
10307            .unwrap();
10308
10309        // Accept the connection and complete the handshake
10310        let accept_timeout =
10311            tokio::time::timeout(Duration::from_secs(2), peer_listener.accept()).await;
10312        if let Ok(Ok((mut stream, _))) = accept_timeout {
10313            // Read handshake
10314            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
10315            if tokio::time::timeout(Duration::from_millis(500), stream.read_exact(&mut hs_buf))
10316                .await
10317                .is_ok()
10318            {
10319                // Send back handshake
10320                let hs = Handshake::new(meta.info_hash, Id20::from([0xBB; 20]));
10321                let hs_bytes = hs.to_bytes();
10322                let _ = stream.write_all(&hs_bytes).await;
10323
10324                // Give the actor time to register the peer
10325                tokio::time::sleep(Duration::from_millis(200)).await;
10326
10327                // Now query peer info
10328                let peer_info = handle.get_peer_info().await.unwrap();
10329                // We should have at least one peer (the one we just handshaked)
10330                if !peer_info.is_empty() {
10331                    let p = &peer_info[0];
10332                    // Verify default choking/interested state
10333                    assert!(p.peer_choking, "peer should be choking us initially");
10334                    // M107: we unconditionally unchoke on connect, so am_choking starts false
10335                    assert!(
10336                        !p.am_choking,
10337                        "we should not be choking peer after connect (M107 unconditional unchoke)"
10338                    );
10339                    assert!(
10340                        !p.peer_interested,
10341                        "peer should not be interested initially"
10342                    );
10343                    assert_eq!(p.num_pieces, 0);
10344                    assert_eq!(p.source, PeerSource::Tracker);
10345                }
10346            }
10347        }
10348        // Even if handshake timing fails, at least verify the API works
10349        let _ = handle.get_peer_info().await.unwrap();
10350        assert_eq!(listen_port, 0); // sanity: initially had no peers
10351
10352        handle.shutdown().await.unwrap();
10353    }
10354
10355    // ---- Test: get_peer_info empty when no peers ----
10356
10357    #[tokio::test]
10358    async fn get_peer_info_empty_when_no_peers() {
10359        let data = vec![0xAB; 32768];
10360        let meta = make_test_torrent(&data, 16384);
10361        let storage = make_storage(&data, 16384);
10362        let config = test_config();
10363
10364        let (atx, amask) = test_alert_channel();
10365        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10366        let handle = TorrentHandle::from_torrent(
10367            meta,
10368            irontide_core::TorrentVersion::V1Only,
10369            None,
10370            dh,
10371            dm,
10372            config,
10373            test_dht_rx(),
10374            test_dht_rx(),
10375            None,
10376            None,
10377            crate::slot_tuner::SlotTuner::disabled(4),
10378            atx,
10379            amask,
10380            None,
10381            None,
10382            test_ban_manager(),
10383            test_ip_filter(),
10384            Arc::new(Vec::new()),
10385            None,
10386            None,
10387            Arc::new(crate::transport::NetworkFactory::tokio()),
10388            None, // M96: hash_pool
10389            Arc::new(crate::stats::SessionCounters::new()),
10390        )
10391        .await
10392        .unwrap();
10393
10394        let peer_info = handle.get_peer_info().await.unwrap();
10395        assert!(peer_info.is_empty(), "should have no peers initially");
10396
10397        handle.shutdown().await.unwrap();
10398    }
10399
10400    // ---- Test: get_download_queue empty initially ----
10401
10402    #[tokio::test]
10403    async fn get_download_queue_empty_initially() {
10404        let data = vec![0xAB; 32768];
10405        let meta = make_test_torrent(&data, 16384);
10406        let storage = make_storage(&data, 16384);
10407        let config = test_config();
10408
10409        let (atx, amask) = test_alert_channel();
10410        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10411        let handle = TorrentHandle::from_torrent(
10412            meta,
10413            irontide_core::TorrentVersion::V1Only,
10414            None,
10415            dh,
10416            dm,
10417            config,
10418            test_dht_rx(),
10419            test_dht_rx(),
10420            None,
10421            None,
10422            crate::slot_tuner::SlotTuner::disabled(4),
10423            atx,
10424            amask,
10425            None,
10426            None,
10427            test_ban_manager(),
10428            test_ip_filter(),
10429            Arc::new(Vec::new()),
10430            None,
10431            None,
10432            Arc::new(crate::transport::NetworkFactory::tokio()),
10433            None, // M96: hash_pool
10434            Arc::new(crate::stats::SessionCounters::new()),
10435        )
10436        .await
10437        .unwrap();
10438
10439        let queue = handle.get_download_queue().await.unwrap();
10440        assert!(
10441            queue.is_empty(),
10442            "download queue should be empty with no active downloads"
10443        );
10444
10445        handle.shutdown().await.unwrap();
10446    }
10447
10448    // ---- Test: have_piece false initially ----
10449
10450    #[tokio::test]
10451    async fn have_piece_false_initially() {
10452        let data = vec![0xAB; 32768]; // 32 KiB = 2 pieces
10453        let meta = make_test_torrent(&data, 16384);
10454        let storage = make_storage(&data, 16384);
10455        let config = test_config();
10456
10457        let (atx, amask) = test_alert_channel();
10458        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10459        let handle = TorrentHandle::from_torrent(
10460            meta,
10461            irontide_core::TorrentVersion::V1Only,
10462            None,
10463            dh,
10464            dm,
10465            config,
10466            test_dht_rx(),
10467            test_dht_rx(),
10468            None,
10469            None,
10470            crate::slot_tuner::SlotTuner::disabled(4),
10471            atx,
10472            amask,
10473            None,
10474            None,
10475            test_ban_manager(),
10476            test_ip_filter(),
10477            Arc::new(Vec::new()),
10478            None,
10479            None,
10480            Arc::new(crate::transport::NetworkFactory::tokio()),
10481            None, // M96: hash_pool
10482            Arc::new(crate::stats::SessionCounters::new()),
10483        )
10484        .await
10485        .unwrap();
10486
10487        assert!(
10488            !handle.have_piece(0).await.unwrap(),
10489            "piece 0 should not be downloaded initially"
10490        );
10491        assert!(
10492            !handle.have_piece(1).await.unwrap(),
10493            "piece 1 should not be downloaded initially"
10494        );
10495
10496        handle.shutdown().await.unwrap();
10497    }
10498
10499    // ---- Test: piece_availability empty with no peers ----
10500
10501    #[tokio::test]
10502    async fn piece_availability_empty_no_peers() {
10503        let data = vec![0xAB; 32768]; // 2 pieces
10504        let meta = make_test_torrent(&data, 16384);
10505        let storage = make_storage(&data, 16384);
10506        let config = test_config();
10507
10508        let (atx, amask) = test_alert_channel();
10509        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10510        let handle = TorrentHandle::from_torrent(
10511            meta,
10512            irontide_core::TorrentVersion::V1Only,
10513            None,
10514            dh,
10515            dm,
10516            config,
10517            test_dht_rx(),
10518            test_dht_rx(),
10519            None,
10520            None,
10521            crate::slot_tuner::SlotTuner::disabled(4),
10522            atx,
10523            amask,
10524            None,
10525            None,
10526            test_ban_manager(),
10527            test_ip_filter(),
10528            Arc::new(Vec::new()),
10529            None,
10530            None,
10531            Arc::new(crate::transport::NetworkFactory::tokio()),
10532            None, // M96: hash_pool
10533            Arc::new(crate::stats::SessionCounters::new()),
10534        )
10535        .await
10536        .unwrap();
10537
10538        let avail = handle.piece_availability().await.unwrap();
10539        assert_eq!(avail.len(), 2, "should have availability for 2 pieces");
10540        assert!(
10541            avail.iter().all(|&c| c == 0),
10542            "all availability counts should be 0 with no peers"
10543        );
10544
10545        handle.shutdown().await.unwrap();
10546    }
10547
10548    // ---- Test: file_progress zeros initially ----
10549
10550    #[tokio::test]
10551    async fn file_progress_zeros_initially() {
10552        let data = vec![0xAB; 32768]; // single-file, 2 pieces
10553        let meta = make_test_torrent(&data, 16384);
10554        let storage = make_storage(&data, 16384);
10555        let config = test_config();
10556
10557        let (atx, amask) = test_alert_channel();
10558        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10559        let handle = TorrentHandle::from_torrent(
10560            meta,
10561            irontide_core::TorrentVersion::V1Only,
10562            None,
10563            dh,
10564            dm,
10565            config,
10566            test_dht_rx(),
10567            test_dht_rx(),
10568            None,
10569            None,
10570            crate::slot_tuner::SlotTuner::disabled(4),
10571            atx,
10572            amask,
10573            None,
10574            None,
10575            test_ban_manager(),
10576            test_ip_filter(),
10577            Arc::new(Vec::new()),
10578            None,
10579            None,
10580            Arc::new(crate::transport::NetworkFactory::tokio()),
10581            None, // M96: hash_pool
10582            Arc::new(crate::stats::SessionCounters::new()),
10583        )
10584        .await
10585        .unwrap();
10586
10587        let progress = handle.file_progress().await.unwrap();
10588        assert_eq!(progress.len(), 1, "single-file torrent should have 1 entry");
10589        assert_eq!(progress[0], 0, "no bytes should be downloaded initially");
10590
10591        handle.shutdown().await.unwrap();
10592    }
10593
10594    // ---- Test: file_progress length matches file count (multi-file) ----
10595
10596    /// Build a multi-file `TorrentMetaV1` from a total data blob and file lengths.
10597    fn make_test_torrent_multi(
10598        data: &[u8],
10599        piece_length: u64,
10600        file_lengths: &[u64],
10601    ) -> TorrentMetaV1 {
10602        use serde::Serialize;
10603
10604        #[derive(Serialize)]
10605        struct FileE {
10606            length: u64,
10607            path: Vec<String>,
10608        }
10609
10610        #[derive(Serialize)]
10611        struct Info<'a> {
10612            name: &'a str,
10613            #[serde(rename = "piece length")]
10614            piece_length: u64,
10615            #[serde(with = "serde_bytes")]
10616            pieces: &'a [u8],
10617            files: Vec<FileE>,
10618        }
10619
10620        #[derive(Serialize)]
10621        struct Torrent<'a> {
10622            info: Info<'a>,
10623        }
10624
10625        let mut pieces = Vec::new();
10626        let mut offset = 0;
10627        while offset < data.len() {
10628            let end = (offset + piece_length as usize).min(data.len());
10629            let hash = irontide_core::sha1(&data[offset..end]);
10630            pieces.extend_from_slice(hash.as_bytes());
10631            offset = end;
10632        }
10633
10634        let files: Vec<FileE> = file_lengths
10635            .iter()
10636            .enumerate()
10637            .map(|(i, &len)| FileE {
10638                length: len,
10639                path: vec![format!("file{i}.bin")],
10640            })
10641            .collect();
10642
10643        let t = Torrent {
10644            info: Info {
10645                name: "test_multi",
10646                piece_length,
10647                pieces: &pieces,
10648                files,
10649            },
10650        };
10651
10652        let bytes = irontide_bencode::to_bytes(&t).unwrap();
10653        torrent_from_bytes(&bytes).unwrap()
10654    }
10655
10656    #[tokio::test]
10657    async fn file_progress_length_matches_file_count() {
10658        // 3 files: 10000 + 20000 + 2768 = 32768 bytes total, 2 pieces of 16384
10659        let data = vec![0xCD; 32768];
10660        let file_lengths = [10000u64, 20000, 2768];
10661        let meta = make_test_torrent_multi(&data, 16384, &file_lengths);
10662        let storage = make_storage(&data, 16384);
10663        let config = test_config();
10664
10665        let (atx, amask) = test_alert_channel();
10666        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10667        let handle = TorrentHandle::from_torrent(
10668            meta,
10669            irontide_core::TorrentVersion::V1Only,
10670            None,
10671            dh,
10672            dm,
10673            config,
10674            test_dht_rx(),
10675            test_dht_rx(),
10676            None,
10677            None,
10678            crate::slot_tuner::SlotTuner::disabled(4),
10679            atx,
10680            amask,
10681            None,
10682            None,
10683            test_ban_manager(),
10684            test_ip_filter(),
10685            Arc::new(Vec::new()),
10686            None,
10687            None,
10688            Arc::new(crate::transport::NetworkFactory::tokio()),
10689            None, // M96: hash_pool
10690            Arc::new(crate::stats::SessionCounters::new()),
10691        )
10692        .await
10693        .unwrap();
10694
10695        let progress = handle.file_progress().await.unwrap();
10696        assert_eq!(
10697            progress.len(),
10698            3,
10699            "multi-file torrent should have 3 entries"
10700        );
10701        assert!(
10702            progress.iter().all(|&b| b == 0),
10703            "all progress should be 0 initially"
10704        );
10705
10706        handle.shutdown().await.unwrap();
10707    }
10708
10709    // ---- Test: is_valid returns true for active torrent ----
10710
10711    #[tokio::test]
10712    async fn is_valid_true_for_active() {
10713        let data = vec![0xAB; 32768];
10714        let meta = make_test_torrent(&data, 16384);
10715        let storage = make_storage(&data, 16384);
10716        let config = test_config();
10717
10718        let (atx, amask) = test_alert_channel();
10719        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10720        let handle = TorrentHandle::from_torrent(
10721            meta,
10722            irontide_core::TorrentVersion::V1Only,
10723            None,
10724            dh,
10725            dm,
10726            config,
10727            test_dht_rx(),
10728            test_dht_rx(),
10729            None,
10730            None,
10731            crate::slot_tuner::SlotTuner::disabled(4),
10732            atx,
10733            amask,
10734            None,
10735            None,
10736            test_ban_manager(),
10737            test_ip_filter(),
10738            Arc::new(Vec::new()),
10739            None,
10740            None,
10741            Arc::new(crate::transport::NetworkFactory::tokio()),
10742            None, // M96: hash_pool
10743            Arc::new(crate::stats::SessionCounters::new()),
10744        )
10745        .await
10746        .unwrap();
10747
10748        assert!(
10749            handle.is_valid(),
10750            "handle should be valid while torrent actor is alive"
10751        );
10752
10753        handle.shutdown().await.unwrap();
10754    }
10755
10756    // ---- Test: is_valid returns false after shutdown ----
10757
10758    #[tokio::test]
10759    async fn is_valid_false_after_remove() {
10760        let data = vec![0xAB; 32768];
10761        let meta = make_test_torrent(&data, 16384);
10762        let storage = make_storage(&data, 16384);
10763        let config = test_config();
10764
10765        let (atx, amask) = test_alert_channel();
10766        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10767        let handle = TorrentHandle::from_torrent(
10768            meta,
10769            irontide_core::TorrentVersion::V1Only,
10770            None,
10771            dh,
10772            dm,
10773            config,
10774            test_dht_rx(),
10775            test_dht_rx(),
10776            None,
10777            None,
10778            crate::slot_tuner::SlotTuner::disabled(4),
10779            atx,
10780            amask,
10781            None,
10782            None,
10783            test_ban_manager(),
10784            test_ip_filter(),
10785            Arc::new(Vec::new()),
10786            None,
10787            None,
10788            Arc::new(crate::transport::NetworkFactory::tokio()),
10789            None, // M96: hash_pool
10790            Arc::new(crate::stats::SessionCounters::new()),
10791        )
10792        .await
10793        .unwrap();
10794
10795        assert!(handle.is_valid());
10796
10797        // Shutdown the torrent (simulating removal)
10798        handle.shutdown().await.unwrap();
10799
10800        // Give the actor time to stop and close the channel
10801        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10802
10803        assert!(
10804            !handle.is_valid(),
10805            "handle should be invalid after shutdown"
10806        );
10807    }
10808
10809    // ---- Test: clear_error resets error state ----
10810
10811    #[tokio::test]
10812    async fn clear_error_resets() {
10813        let data = vec![0xAB; 32768];
10814        let meta = make_test_torrent(&data, 16384);
10815        let storage = make_storage(&data, 16384);
10816        let config = test_config();
10817
10818        let (atx, amask) = test_alert_channel();
10819        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10820        let handle = TorrentHandle::from_torrent(
10821            meta,
10822            irontide_core::TorrentVersion::V1Only,
10823            None,
10824            dh,
10825            dm,
10826            config,
10827            test_dht_rx(),
10828            test_dht_rx(),
10829            None,
10830            None,
10831            crate::slot_tuner::SlotTuner::disabled(4),
10832            atx,
10833            amask,
10834            None,
10835            None,
10836            test_ban_manager(),
10837            test_ip_filter(),
10838            Arc::new(Vec::new()),
10839            None,
10840            None,
10841            Arc::new(crate::transport::NetworkFactory::tokio()),
10842            None, // M96: hash_pool
10843            Arc::new(crate::stats::SessionCounters::new()),
10844        )
10845        .await
10846        .unwrap();
10847
10848        // Initially no error
10849        let stats = handle.stats().await.unwrap();
10850        assert!(stats.error.is_empty());
10851        assert_eq!(stats.error_file, -1);
10852
10853        // Clear error (no-op when no error) should succeed without issue
10854        handle.clear_error().await.unwrap();
10855
10856        let stats = handle.stats().await.unwrap();
10857        assert!(stats.error.is_empty());
10858        assert_eq!(stats.error_file, -1);
10859
10860        handle.shutdown().await.unwrap();
10861    }
10862
10863    // ---- Test: flags round trip ----
10864
10865    #[tokio::test]
10866    async fn flags_round_trip() {
10867        let data = vec![0xAB; 32768];
10868        let meta = make_test_torrent(&data, 16384);
10869        let storage = make_storage(&data, 16384);
10870        let config = test_config();
10871
10872        let (atx, amask) = test_alert_channel();
10873        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10874        let handle = TorrentHandle::from_torrent(
10875            meta,
10876            irontide_core::TorrentVersion::V1Only,
10877            None,
10878            dh,
10879            dm,
10880            config,
10881            test_dht_rx(),
10882            test_dht_rx(),
10883            None,
10884            None,
10885            crate::slot_tuner::SlotTuner::disabled(4),
10886            atx,
10887            amask,
10888            None,
10889            None,
10890            test_ban_manager(),
10891            test_ip_filter(),
10892            Arc::new(Vec::new()),
10893            None,
10894            None,
10895            Arc::new(crate::transport::NetworkFactory::tokio()),
10896            None, // M96: hash_pool
10897            Arc::new(crate::stats::SessionCounters::new()),
10898        )
10899        .await
10900        .unwrap();
10901
10902        // Initial flags: torrent starts downloading (not paused), no sequential, no super seeding
10903        let initial = handle.flags().await.unwrap();
10904        assert!(!initial.contains(crate::types::TorrentFlags::PAUSED));
10905        assert!(!initial.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10906        assert!(!initial.contains(crate::types::TorrentFlags::SUPER_SEEDING));
10907
10908        // Enable sequential download via set_flags
10909        handle
10910            .set_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10911            .await
10912            .unwrap();
10913        let after_set = handle.flags().await.unwrap();
10914        assert!(after_set.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10915
10916        // Disable it via unset_flags
10917        handle
10918            .unset_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
10919            .await
10920            .unwrap();
10921        let after_unset = handle.flags().await.unwrap();
10922        assert!(!after_unset.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
10923
10924        // Verify sequential_download state via the dedicated query
10925        assert!(!handle.is_sequential_download().await.unwrap());
10926
10927        handle.shutdown().await.unwrap();
10928    }
10929
10930    // ---- Test: connect_peer does not error ----
10931
10932    #[tokio::test]
10933    async fn connect_peer_no_error() {
10934        let data = vec![0xAB; 32768];
10935        let meta = make_test_torrent(&data, 16384);
10936        let storage = make_storage(&data, 16384);
10937        let config = test_config();
10938
10939        let (atx, amask) = test_alert_channel();
10940        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10941        let handle = TorrentHandle::from_torrent(
10942            meta,
10943            irontide_core::TorrentVersion::V1Only,
10944            None,
10945            dh,
10946            dm,
10947            config,
10948            test_dht_rx(),
10949            test_dht_rx(),
10950            None,
10951            None,
10952            crate::slot_tuner::SlotTuner::disabled(4),
10953            atx,
10954            amask,
10955            None,
10956            None,
10957            test_ban_manager(),
10958            test_ip_filter(),
10959            Arc::new(Vec::new()),
10960            None,
10961            None,
10962            Arc::new(crate::transport::NetworkFactory::tokio()),
10963            None, // M96: hash_pool
10964            Arc::new(crate::stats::SessionCounters::new()),
10965        )
10966        .await
10967        .unwrap();
10968
10969        // connect_peer should not error even though the peer doesn't exist
10970        // (the connection attempt will fail asynchronously, but the command itself succeeds)
10971        let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap();
10972        handle.connect_peer(addr).await.unwrap();
10973
10974        // Give the actor a moment to process
10975        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
10976
10977        handle.shutdown().await.unwrap();
10978    }
10979
10980    // ---- BEP 52 hash serving tests (M87) ----
10981
10982    /// Build a minimal `TorrentMetaV2` with piece-layer hashes for testing.
10983    fn make_test_meta_v2(
10984        piece_hashes: &[irontide_core::Id32],
10985        file_root: irontide_core::Id32,
10986        piece_length: u64,
10987        file_length: u64,
10988    ) -> irontide_core::TorrentMetaV2 {
10989        use std::collections::BTreeMap;
10990
10991        // Concatenate piece hashes into raw bytes
10992        let mut layer_bytes = Vec::with_capacity(piece_hashes.len() * 32);
10993        for h in piece_hashes {
10994            layer_bytes.extend_from_slice(&h.0);
10995        }
10996
10997        let mut piece_layers = BTreeMap::new();
10998        piece_layers.insert(file_root, layer_bytes);
10999
11000        let file_tree = irontide_core::FileTreeNode::Directory({
11001            let mut children = BTreeMap::new();
11002            children.insert(
11003                "test.dat".to_string(),
11004                irontide_core::FileTreeNode::File(irontide_core::V2FileAttr {
11005                    length: file_length,
11006                    pieces_root: Some(file_root),
11007                }),
11008            );
11009            children
11010        });
11011
11012        irontide_core::TorrentMetaV2 {
11013            info_hashes: irontide_core::InfoHashes::v2_only(irontide_core::Id32::ZERO),
11014            info_bytes: None,
11015            announce: None,
11016            announce_list: None,
11017            comment: None,
11018            created_by: None,
11019            creation_date: None,
11020            info: irontide_core::InfoDictV2 {
11021                name: "test".to_string(),
11022                piece_length,
11023                meta_version: 2,
11024                file_tree,
11025                ssl_cert: None,
11026            },
11027            piece_layers,
11028            ssl_cert: None,
11029        }
11030    }
11031
11032    #[test]
11033    fn test_serve_hashes_v2_piece_layer() {
11034        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11035        // => blocks_per_piece = 1, piece_layer_base = 0
11036        let hashes: Vec<irontide_core::Id32> = (0..4u8)
11037            .map(|i| {
11038                let mut h = [0u8; 32];
11039                h[0] = i;
11040                irontide_core::Id32(h)
11041            })
11042            .collect();
11043        let file_root = irontide_core::Id32([0xAA; 32]);
11044        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11045        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11046
11047        let request = irontide_core::HashRequest {
11048            file_root,
11049            base: 0, // piece layer when blocks_per_piece = 1
11050            index: 0,
11051            count: 4,
11052            proof_layers: 0,
11053        };
11054
11055        let result = serve_hashes(
11056            Some(&meta),
11057            irontide_core::TorrentVersion::V2Only,
11058            Some(&lengths),
11059            &request,
11060        );
11061        let served = result.expect("should serve hashes");
11062        assert_eq!(served.len(), 4);
11063        for (i, h) in served.iter().enumerate() {
11064            assert_eq!(h.0[0], i as u8);
11065        }
11066    }
11067
11068    #[test]
11069    fn test_serve_hashes_rejects_v1_only() {
11070        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11071        let file_root = irontide_core::Id32([0xAA; 32]);
11072        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11073        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11074
11075        let request = irontide_core::HashRequest {
11076            file_root,
11077            base: 0,
11078            index: 0,
11079            count: 1,
11080            proof_layers: 0,
11081        };
11082
11083        let result = serve_hashes(
11084            Some(&meta),
11085            irontide_core::TorrentVersion::V1Only,
11086            Some(&lengths),
11087            &request,
11088        );
11089        assert!(result.is_none(), "V1Only should reject hash requests");
11090    }
11091
11092    #[test]
11093    fn test_serve_hashes_rejects_unknown_root() {
11094        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
11095        let file_root = irontide_core::Id32([0xAA; 32]);
11096        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
11097        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
11098
11099        // Request a different file root that doesn't exist
11100        let unknown_root = irontide_core::Id32([0xFF; 32]);
11101        let request = irontide_core::HashRequest {
11102            file_root: unknown_root,
11103            base: 0,
11104            index: 0,
11105            count: 1,
11106            proof_layers: 0,
11107        };
11108
11109        let result = serve_hashes(
11110            Some(&meta),
11111            irontide_core::TorrentVersion::V2Only,
11112            Some(&lengths),
11113            &request,
11114        );
11115        assert!(result.is_none(), "unknown file_root should reject");
11116    }
11117
11118    #[test]
11119    fn test_serve_hashes_rejects_out_of_bounds() {
11120        // 2 piece hashes, piece_length = 16384, chunk_size = 16384
11121        let hashes: Vec<irontide_core::Id32> =
11122            (0..2u8).map(|i| irontide_core::Id32([i; 32])).collect();
11123        let file_root = irontide_core::Id32([0xAA; 32]);
11124        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 2);
11125        let lengths = Lengths::new(16384 * 2, 16384, DEFAULT_CHUNK_SIZE);
11126
11127        // Request starting at index 5, which is beyond the 2 available hashes
11128        let request = irontide_core::HashRequest {
11129            file_root,
11130            base: 0,
11131            index: 5,
11132            count: 1,
11133            proof_layers: 0,
11134        };
11135
11136        let result = serve_hashes(
11137            Some(&meta),
11138            irontide_core::TorrentVersion::V2Only,
11139            Some(&lengths),
11140            &request,
11141        );
11142        assert!(result.is_none(), "out-of-bounds index should reject");
11143    }
11144
11145    #[test]
11146    fn test_serve_hashes_includes_proofs() {
11147        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11148        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11149        let hashes: Vec<irontide_core::Id32> =
11150            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11151        let file_root = irontide_core::Id32([0xAA; 32]);
11152        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11153        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11154
11155        // Request 1 hash with 1 proof layer
11156        let request = irontide_core::HashRequest {
11157            file_root,
11158            base: 0,
11159            index: 0,
11160            count: 1,
11161            proof_layers: 1,
11162        };
11163
11164        let result = serve_hashes(
11165            Some(&meta),
11166            irontide_core::TorrentVersion::V2Only,
11167            Some(&lengths),
11168            &request,
11169        );
11170        let served = result.expect("should serve hashes with proofs");
11171        // 1 requested hash + 1 proof hash (sibling of leaf 0) = 2 total
11172        assert_eq!(served.len(), 2, "should have 1 data hash + 1 proof hash");
11173        // First hash is the requested piece hash
11174        assert_eq!(served[0], hashes[0]);
11175        // Second hash is the sibling (proof) — which is hashes[1]
11176        assert_eq!(served[1], hashes[1]);
11177    }
11178
11179    #[test]
11180    fn test_serve_hashes_proof_with_batch() {
11181        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
11182        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
11183        //
11184        // Tree layout (1-indexed heap):
11185        //          [1] root
11186        //        /          \
11187        //     [2]            [3]
11188        //    /    \         /    \
11189        //  [4]h0  [5]h1  [6]h2  [7]h3
11190        //
11191        // Request count=2 at index=0 => subtree rooted at [2] (h0, h1).
11192        // subtree_depth = log2(2) = 1, so we skip 1 level of the proof path.
11193        // proof_path(0) = [h1, hash(h2,h3)] — h1 is internal to subtree,
11194        // hash(h2,h3) is the uncle above. We skip h1 and send hash(h2,h3).
11195        let hashes: Vec<irontide_core::Id32> =
11196            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
11197        let file_root = irontide_core::Id32([0xAA; 32]);
11198        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
11199        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
11200
11201        let request = irontide_core::HashRequest {
11202            file_root,
11203            base: 0,
11204            index: 0,
11205            count: 2,
11206            proof_layers: 1,
11207        };
11208
11209        let result = serve_hashes(
11210            Some(&meta),
11211            irontide_core::TorrentVersion::V2Only,
11212            Some(&lengths),
11213            &request,
11214        );
11215        let served = result.expect("should serve hashes with batch proof");
11216        // 2 base hashes + 1 uncle hash = 3 total
11217        assert_eq!(served.len(), 3, "should have 2 data hashes + 1 uncle hash");
11218        // First two are the requested piece hashes
11219        assert_eq!(served[0], hashes[0]);
11220        assert_eq!(served[1], hashes[1]);
11221        // Third is the uncle: sibling of the subtree root at [2],
11222        // which is the node at [3] = hash(h2, h3)
11223        let tree = irontide_core::MerkleTree::from_leaves(&hashes);
11224        let expected_uncle = tree.layer(1)[1]; // layer 1 has 2 nodes; index 1 is the right one
11225        assert_eq!(served[2], expected_uncle);
11226
11227        // Verify the proof is valid: reconstruct subtree root from base hashes,
11228        // then verify against the tree root using the uncle hash
11229        let sub_root = irontide_core::MerkleTree::root_from_hashes(&served[..2]);
11230        let uncle_hashes = &served[2..];
11231        let leaf_index = request.index as usize / 2; // 0 / 2 = 0
11232        assert!(
11233            irontide_core::MerkleTree::verify_proof(
11234                tree.root(),
11235                sub_root,
11236                leaf_index,
11237                uncle_hashes
11238            ),
11239            "subtree proof should verify against tree root"
11240        );
11241    }
11242
11243    #[test]
11244    fn is_i2p_synthetic_addr_detects_240_range() {
11245        assert!(is_i2p_synthetic_addr(&"240.0.0.1:1".parse().unwrap()));
11246        assert!(is_i2p_synthetic_addr(
11247            &"255.255.255.255:65535".parse().unwrap()
11248        ));
11249        assert!(!is_i2p_synthetic_addr(&"192.168.1.1:6881".parse().unwrap()));
11250        assert!(!is_i2p_synthetic_addr(&"[::1]:6881".parse().unwrap()));
11251    }
11252
11253    #[test]
11254    fn v6_retry_delay_progression() {
11255        // Verify exponential backoff: 100, 200, 400, 800, 1600, 3200, 5000, 5000...
11256        let expected_ms = [100, 200, 400, 800, 1600, 3200, 5000, 5000, 5000, 5000, 5000];
11257        for (count, &expected) in expected_ms.iter().enumerate() {
11258            let delay_ms = {
11259                let base_ms: u64 = 100;
11260                let max_ms: u64 = 5000;
11261                base_ms
11262                    .saturating_mul(1u64.checked_shl(count as u32).unwrap_or(u64::MAX))
11263                    .min(max_ms)
11264            };
11265            assert_eq!(
11266                delay_ms, expected,
11267                "count={count}: expected {expected}ms, got {delay_ms}ms"
11268            );
11269        }
11270    }
11271
11272    // ---- M104: Per-peer backoff and max_in_flight formula tests ----
11273
11274    #[test]
11275    fn peer_backoff_exponential() {
11276        // Verify the M104 backoff formula: 200ms * 2^attempt, capped at 30s.
11277        // attempt starts at 1 (first failure increments 0 → 1).
11278        let expected_ms: Vec<u64> = vec![400, 800, 1600, 3200, 6400, 12800, 25600, 30000, 30000];
11279        for (i, &expected) in expected_ms.iter().enumerate() {
11280            let attempt = (i as u32) + 1; // attempt counts start at 1
11281            let delay_ms = 200u64.saturating_mul(1u64 << attempt.min(10)).min(30_000);
11282            assert_eq!(
11283                delay_ms, expected,
11284                "attempt={attempt}: expected {expected}ms, got {delay_ms}ms"
11285            );
11286        }
11287    }
11288
11289    #[test]
11290    fn peer_backoff_clears_on_data() {
11291        // Verify that backoff map operations work correctly:
11292        // insert on disconnect, remove on data received.
11293        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11294        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11295
11296        // No backoff initially
11297        assert!(!backoff.contains_key(&addr));
11298
11299        // First disconnect: attempt 1
11300        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11301        let next = attempt.saturating_add(1);
11302        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11303        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11304        backoff.insert(addr, (earliest, next));
11305        assert_eq!(backoff.get(&addr).unwrap().1, 1);
11306
11307        // Second disconnect: attempt 2
11308        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
11309        let next = attempt.saturating_add(1);
11310        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
11311        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
11312        backoff.insert(addr, (earliest, next));
11313        assert_eq!(backoff.get(&addr).unwrap().1, 2);
11314
11315        // Data received: clear
11316        backoff.remove(&addr);
11317        assert!(!backoff.contains_key(&addr));
11318    }
11319
11320    #[test]
11321    fn backoff_prevents_hammering() {
11322        // Verify that a peer with a future backoff time would be skipped.
11323        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
11324        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
11325
11326        // Set backoff 10 seconds in the future
11327        let future = std::time::Instant::now() + Duration::from_secs(10);
11328        backoff.insert(addr, (future, 3));
11329
11330        // Should be skipped (now < next_attempt)
11331        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11332            assert!(std::time::Instant::now() < next_attempt);
11333        }
11334
11335        // Set backoff in the past — should NOT be skipped
11336        let past = std::time::Instant::now() - Duration::from_secs(1);
11337        backoff.insert(addr, (past, 3));
11338        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
11339            assert!(std::time::Instant::now() >= next_attempt);
11340        }
11341    }
11342
11343    #[test]
11344    fn max_in_flight_formula_updated() {
11345        // M104: max(512, connected*4) clamped to pieces/2, floored at 512.
11346        let formula = |connected: usize, num_pieces: u32| -> usize {
11347            let calculated = 512usize.max(connected.saturating_mul(4));
11348            calculated.min(num_pieces as usize / 2).max(512)
11349        };
11350
11351        // Few peers: floor dominates
11352        assert_eq!(formula(10, 2000), 512);
11353
11354        // Many peers: connected * 4 takes over
11355        assert_eq!(formula(200, 2000), 800);
11356
11357        // Very many peers: clamped by pieces/2
11358        assert_eq!(formula(500, 2000), 1000); // 2000 clamped to 1000
11359
11360        // Tiny torrent: floor dominates even with many peers
11361        assert_eq!(formula(200, 100), 512); // 800 clamped to 50, floored to 512
11362
11363        // Exact boundary
11364        assert_eq!(formula(128, 10000), 512); // 128*4=512, max(512,512)=512
11365        assert_eq!(formula(129, 10000), 516); // 129*4=516, max(512,516)=516
11366
11367        // Zero peers
11368        assert_eq!(formula(0, 10000), 512);
11369
11370        // Zero pieces (edge case — would give pieces/2=0, floor=512)
11371        assert_eq!(formula(100, 0), 512);
11372    }
11373
11374    // -- BEP 55 holepunch initiation tests (M112) --
11375
11376    #[test]
11377    fn should_attempt_holepunch_reason_classification() {
11378        // NAT-related reasons → true
11379        assert!(should_attempt_holepunch("connection refused"));
11380        assert!(should_attempt_holepunch("Connection refused"));
11381        assert!(should_attempt_holepunch("timed out"));
11382        assert!(should_attempt_holepunch("Connection reset by peer"));
11383        assert!(should_attempt_holepunch("connection reset by peer"));
11384        // Re-entrancy guard: holepunch-originated failures → false
11385        assert!(!should_attempt_holepunch(
11386            "holepunch TCP connect failed: Connection refused"
11387        ));
11388        // Non-NAT reasons → false
11389        assert!(!should_attempt_holepunch("peer banned"));
11390        assert!(!should_attempt_holepunch("protocol error"));
11391        assert!(!should_attempt_holepunch(""));
11392    }
11393
11394    #[test]
11395    fn holepunch_initiation_on_connect_failure() {
11396        // "connection refused" is the canonical NAT failure reason
11397        assert!(should_attempt_holepunch("connection refused"));
11398    }
11399
11400    #[test]
11401    fn holepunch_cooldown_prevents_retry() {
11402        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11403        let addr: SocketAddr = "127.0.0.1:6881".parse().expect("valid test addr");
11404        let now = Instant::now();
11405        cooldowns.insert(addr, now);
11406        // addr is in cooldowns, so should be skipped on subsequent attempt
11407        assert!(cooldowns.contains_key(&addr));
11408    }
11409
11410    #[test]
11411    fn holepunch_cooldown_overflow_skips() {
11412        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
11413        let now = Instant::now();
11414        for i in 0..256u16 {
11415            let addr: SocketAddr = format!("10.0.{}.{}:6881", i / 256, i % 256)
11416                .parse()
11417                .expect("valid test addr");
11418            cooldowns.insert(addr, now);
11419        }
11420        assert_eq!(cooldowns.len(), HOLEPUNCH_MAX_TRACKED);
11421        // New entry should be skipped when at capacity
11422    }
11423
11424    #[test]
11425    fn holepunch_skipped_when_disabled() {
11426        // should_attempt_holepunch only checks the reason string, not config.
11427        // Config check happens in disconnect_peer.
11428        assert!(should_attempt_holepunch("connection refused"));
11429        // This test documents that should_attempt_holepunch is reason-only.
11430    }
11431
11432    #[test]
11433    fn holepunch_not_triggered_on_ban() {
11434        assert!(!should_attempt_holepunch("peer banned"));
11435        assert!(!should_attempt_holepunch("banned for bad data"));
11436    }
11437
11438    // -- M116: CachedFileInfo tests --
11439
11440    /// Helper to build a minimal `TorrentMetaV1` with multi-file entries.
11441    fn make_multi_file_meta(files: &[(u64, &str)], piece_length: u64) -> TorrentMetaV1 {
11442        let total_length: u64 = files.iter().map(|(len, _)| *len).sum();
11443        let num_pieces = total_length.div_ceil(piece_length) as usize;
11444        let file_entries: Vec<irontide_core::FileEntry> = files
11445            .iter()
11446            .map(|(length, name)| irontide_core::FileEntry {
11447                length: *length,
11448                path: vec![name.to_string()],
11449                attr: None,
11450                mtime: None,
11451                symlink_path: None,
11452            })
11453            .collect();
11454        TorrentMetaV1 {
11455            info_hash: Id20([0u8; 20]),
11456            announce: None,
11457            announce_list: None,
11458            comment: None,
11459            created_by: None,
11460            creation_date: None,
11461            info: irontide_core::InfoDict {
11462                name: "test".to_string(),
11463                piece_length,
11464                pieces: vec![0u8; num_pieces * 20],
11465                length: None,
11466                files: Some(file_entries),
11467                private: None,
11468                source: None,
11469                ssl_cert: None,
11470                similar: Vec::new(),
11471                collections: Vec::new(),
11472            },
11473            url_list: Vec::new(),
11474            httpseeds: Vec::new(),
11475            info_bytes: None,
11476            ssl_cert: None,
11477        }
11478    }
11479
11480    #[test]
11481    fn cached_files_populated_on_registration() {
11482        // 3 files: 100, 200, 50 bytes; piece_length = 100
11483        // Total = 350 bytes, 4 pieces (0..3)
11484        // File 0: offset 0..100  -> pieces [0, 0]
11485        // File 1: offset 100..300 -> pieces [1, 2]
11486        // File 2: offset 300..350 -> pieces [3, 3]
11487        let meta = make_multi_file_meta(&[(100, "a.txt"), (200, "b.txt"), (50, "c.txt")], 100);
11488        let lengths = Lengths::new(350, 100, 16384);
11489        let cached = build_cached_file_info(&meta, &lengths);
11490
11491        assert_eq!(cached.entries.len(), 3);
11492
11493        assert_eq!(cached.entries[0].index, 0);
11494        assert_eq!(cached.entries[0].length, 100);
11495        assert_eq!(cached.entries[0].first_piece, 0);
11496        assert_eq!(cached.entries[0].last_piece, 0);
11497
11498        assert_eq!(cached.entries[1].index, 1);
11499        assert_eq!(cached.entries[1].length, 200);
11500        assert_eq!(cached.entries[1].first_piece, 1);
11501        assert_eq!(cached.entries[1].last_piece, 2);
11502
11503        assert_eq!(cached.entries[2].index, 2);
11504        assert_eq!(cached.entries[2].length, 50);
11505        assert_eq!(cached.entries[2].first_piece, 3);
11506        assert_eq!(cached.entries[2].last_piece, 3);
11507    }
11508
11509    #[test]
11510    fn cached_files_single_file_torrent() {
11511        // Single-file torrent: 500 bytes, piece_length = 100
11512        // 5 pieces (0..4)
11513        let meta = TorrentMetaV1 {
11514            info_hash: Id20([0u8; 20]),
11515            announce: None,
11516            announce_list: None,
11517            comment: None,
11518            created_by: None,
11519            creation_date: None,
11520            info: irontide_core::InfoDict {
11521                name: "single.bin".to_string(),
11522                piece_length: 100,
11523                pieces: vec![0u8; 5 * 20],
11524                length: Some(500),
11525                files: None,
11526                private: None,
11527                source: None,
11528                ssl_cert: None,
11529                similar: Vec::new(),
11530                collections: Vec::new(),
11531            },
11532            url_list: Vec::new(),
11533            httpseeds: Vec::new(),
11534            info_bytes: None,
11535            ssl_cert: None,
11536        };
11537        let lengths = Lengths::new(500, 100, 16384);
11538        let cached = build_cached_file_info(&meta, &lengths);
11539
11540        assert_eq!(cached.entries.len(), 1);
11541        assert_eq!(cached.entries[0].index, 0);
11542        assert_eq!(cached.entries[0].length, 500);
11543        assert_eq!(cached.entries[0].first_piece, 0);
11544        assert_eq!(cached.entries[0].last_piece, 4);
11545    }
11546
11547    // ── M132: Time-based steal-queue population tests ──
11548    //
11549    // These tests verify the steal-populate logic that runs in run_steal_queue_maintenance().
11550    // They build AtomicPieceStates and StealCandidates directly and exercise the
11551    // same scan loop used by the real implementation.
11552
11553    use crate::piece_reservation::{AtomicPieceStates, PieceState, StealCandidates};
11554    use irontide_storage::Bitfield;
11555
11556    /// Helper: run the steal-populate scan (mirrors `run_steal_queue_maintenance`).
11557    ///
11558    /// Returns the number of pieces pushed into the steal queue.
11559    fn steal_populate_scan(states: &AtomicPieceStates, sc: &StealCandidates) -> u32 {
11560        let mut pushed = 0u32;
11561        let num = states.len();
11562        for piece in 0..num {
11563            let state = states.get(piece);
11564            if state == PieceState::Reserved {
11565                sc.push(piece);
11566                pushed = pushed.saturating_add(1);
11567            }
11568        }
11569        pushed
11570    }
11571
11572    fn all_wanted(n: u32) -> Bitfield {
11573        let mut bf = Bitfield::new(n);
11574        for i in 0..n {
11575            bf.set(i);
11576        }
11577        bf
11578    }
11579
11580    #[test]
11581    fn steal_populate_pushes_reserved_pieces() {
11582        let n = 10;
11583        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11584        let sc = StealCandidates::new();
11585
11586        // Reserve pieces 2, 5, 7
11587        assert!(states.try_reserve(2));
11588        assert!(states.try_reserve(5));
11589        assert!(states.try_reserve(7));
11590
11591        let pushed = steal_populate_scan(&states, &sc);
11592        assert_eq!(pushed, 3, "should push exactly the 3 reserved pieces");
11593
11594        // Verify they're in the queue
11595        let mut popped = Vec::new();
11596        while let Some(p) = sc.pop() {
11597            popped.push(p);
11598        }
11599        popped.sort_unstable();
11600        assert_eq!(popped, vec![2, 5, 7]);
11601    }
11602
11603    #[test]
11604    fn steal_populate_skips_non_reserved_states() {
11605        let n = 8;
11606        let mut have = Bitfield::new(n);
11607        have.set(0); // piece 0 = Complete
11608        let mut wanted = all_wanted(n);
11609        wanted.clear(1); // piece 1 = Unwanted
11610
11611        let states = AtomicPieceStates::new(n, &have, &wanted);
11612        let sc = StealCandidates::new();
11613
11614        // Reserve piece 3, leave rest as Available/Complete/Unwanted
11615        assert!(states.try_reserve(3));
11616
11617        let pushed = steal_populate_scan(&states, &sc);
11618        assert_eq!(pushed, 1, "only piece 3 (Reserved) should be pushed");
11619
11620        assert_eq!(sc.pop(), Some(3));
11621        assert_eq!(sc.pop(), None);
11622    }
11623
11624    #[test]
11625    fn steal_populate_deduplicates() {
11626        let n = 4;
11627        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11628        let sc = StealCandidates::new();
11629
11630        assert!(states.try_reserve(1));
11631        assert!(states.try_reserve(2));
11632
11633        // First scan pushes 2 pieces
11634        let pushed1 = steal_populate_scan(&states, &sc);
11635        assert_eq!(pushed1, 2);
11636
11637        // Second scan: StealCandidates.push() deduplicates, so the queue
11638        // should still contain exactly 2 entries, not 4.
11639        let pushed2 = steal_populate_scan(&states, &sc);
11640        assert_eq!(pushed2, 2, "scan still reports 2 reserved pieces");
11641
11642        let mut count = 0u32;
11643        while sc.pop().is_some() {
11644            count = count.saturating_add(1);
11645        }
11646        assert_eq!(count, 2, "dedup means only 2 entries despite 2 scans");
11647    }
11648
11649    #[test]
11650    fn steal_populate_skips_completed_pieces() {
11651        let n = 5;
11652        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11653        let sc = StealCandidates::new();
11654
11655        // Reserve all 5 pieces
11656        for i in 0..n {
11657            assert!(states.try_reserve(i));
11658        }
11659
11660        // Complete pieces 1 and 3 before the scan
11661        states.mark_complete(1);
11662        states.mark_complete(3);
11663
11664        let pushed = steal_populate_scan(&states, &sc);
11665        assert_eq!(pushed, 3, "3 pieces still Reserved (0, 2, 4)");
11666
11667        let mut popped = Vec::new();
11668        while let Some(p) = sc.pop() {
11669            popped.push(p);
11670        }
11671        popped.sort_unstable();
11672        assert_eq!(popped, vec![0, 2, 4]);
11673    }
11674
11675    #[test]
11676    fn steal_populate_empty_when_no_reserved() {
11677        let n = 6;
11678        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11679        let sc = StealCandidates::new();
11680
11681        // No pieces reserved — scan should push nothing
11682        let pushed = steal_populate_scan(&states, &sc);
11683        assert_eq!(pushed, 0);
11684        assert_eq!(sc.pop(), None);
11685    }
11686
11687    #[test]
11688    fn steal_populate_with_endgame_pieces() {
11689        // Endgame pieces (state = Endgame) should NOT be pushed — only Reserved.
11690        let n = 4;
11691        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11692        let sc = StealCandidates::new();
11693
11694        assert!(states.try_reserve(0));
11695        assert!(states.try_reserve(1));
11696        states.transition_to_endgame(1);
11697
11698        let pushed = steal_populate_scan(&states, &sc);
11699        assert_eq!(
11700            pushed, 1,
11701            "only piece 0 (Reserved) should be pushed, not piece 1 (Endgame)"
11702        );
11703        assert_eq!(sc.pop(), Some(0));
11704        assert_eq!(sc.pop(), None);
11705    }
11706
11707    // -------------------------------------------------------------------
11708    // F8: Piece state sync on file priority change
11709    // -------------------------------------------------------------------
11710
11711    #[test]
11712    fn sync_piece_states_marks_unwanted_on_skip() {
11713        let n = 8;
11714        let mut wanted = all_wanted(n);
11715        wanted.clear(2);
11716        wanted.clear(3);
11717        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11718        // Simulate: wanted_pieces was rebuilt but atomic_states not yet synced.
11719        // Pieces 2 and 3 are Available but no longer wanted.
11720        assert_eq!(states.get(2), PieceState::Available);
11721        assert_eq!(states.get(3), PieceState::Available);
11722
11723        // Run the sync logic directly.
11724        for piece in 0..n {
11725            let w = wanted.get(piece);
11726            let current = states.get(piece);
11727            if !w && current == PieceState::Available {
11728                states.mark_unwanted(piece);
11729            } else if w && current == PieceState::Unwanted {
11730                states.mark_available(piece);
11731            }
11732        }
11733
11734        assert_eq!(states.get(0), PieceState::Available);
11735        assert_eq!(states.get(2), PieceState::Unwanted);
11736        assert_eq!(states.get(3), PieceState::Unwanted);
11737        assert_eq!(states.get(4), PieceState::Available);
11738    }
11739
11740    #[test]
11741    fn sync_piece_states_restores_available_on_unskip() {
11742        let n = 6;
11743        let mut initial_wanted = all_wanted(n);
11744        initial_wanted.clear(1);
11745        initial_wanted.clear(4);
11746        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &initial_wanted);
11747        assert_eq!(states.get(1), PieceState::Unwanted);
11748        assert_eq!(states.get(4), PieceState::Unwanted);
11749
11750        // Now re-enable all pieces (simulate setting back to Normal).
11751        let new_wanted = all_wanted(n);
11752        for piece in 0..n {
11753            let w = new_wanted.get(piece);
11754            let current = states.get(piece);
11755            if !w && current == PieceState::Available {
11756                states.mark_unwanted(piece);
11757            } else if w && current == PieceState::Unwanted {
11758                states.mark_available(piece);
11759            }
11760        }
11761
11762        assert_eq!(states.get(1), PieceState::Available);
11763        assert_eq!(states.get(4), PieceState::Available);
11764    }
11765
11766    #[test]
11767    fn sync_piece_states_shared_piece_stays_available() {
11768        // A piece spanning a skipped and non-skipped file stays wanted
11769        // (build_wanted_pieces marks it wanted if any spanning file is
11770        // non-skip). Verify the sync leaves it Available.
11771        let n = 4;
11772        let mut wanted = all_wanted(n);
11773        wanted.clear(0); // exclusive to skipped file
11774        // Piece 1 is shared — stays wanted
11775        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
11776
11777        for piece in 0..n {
11778            let w = wanted.get(piece);
11779            let current = states.get(piece);
11780            if !w && current == PieceState::Available {
11781                states.mark_unwanted(piece);
11782            } else if w && current == PieceState::Unwanted {
11783                states.mark_available(piece);
11784            }
11785        }
11786
11787        assert_eq!(states.get(0), PieceState::Unwanted);
11788        assert_eq!(
11789            states.get(1),
11790            PieceState::Available,
11791            "shared piece stays Available"
11792        );
11793        assert_eq!(states.get(2), PieceState::Available);
11794        assert_eq!(states.get(3), PieceState::Available);
11795    }
11796
11797    // -------------------------------------------------------------------
11798    // M133: DHT re-query tests
11799    // -------------------------------------------------------------------
11800
11801    /// Verify the DHT re-query guard scales with `max_peers` config.
11802    ///
11803    /// The guard threshold is `max_peers * 4`. With default `max_peers = 128`,
11804    /// this becomes 512 (close to the old hardcoded 500).
11805    #[test]
11806    fn dht_requery_guard_scales_with_max_peers() {
11807        // max_peers = 128 → threshold = 512
11808        assert_eq!(128_usize.saturating_mul(4), 512);
11809
11810        // max_peers = 200 → threshold = 800
11811        assert_eq!(200_usize.saturating_mul(4), 800);
11812
11813        // max_peers = 50 → threshold = 200
11814        assert_eq!(50_usize.saturating_mul(4), 200);
11815
11816        // Overflow protection: saturating_mul handles usize::MAX
11817        assert_eq!(usize::MAX.saturating_mul(4), usize::MAX);
11818    }
11819
11820    // ---- M147: Pre-resolved metadata tests ----
11821
11822    /// Build a raw bencoded info dict and its SHA1 hash (for magnet link testing).
11823    fn make_test_info_bytes() -> (Vec<u8>, Id20) {
11824        use serde::Serialize;
11825
11826        #[derive(Serialize)]
11827        struct Info<'a> {
11828            length: u64,
11829            name: &'a str,
11830            #[serde(rename = "piece length")]
11831            piece_length: u64,
11832            #[serde(with = "serde_bytes")]
11833            pieces: &'a [u8],
11834        }
11835
11836        let data = vec![0xAB; 1024];
11837        let piece_hash = irontide_core::sha1(&data);
11838        let mut pieces = Vec::new();
11839        pieces.extend_from_slice(piece_hash.as_bytes());
11840
11841        let info = Info {
11842            length: 1024,
11843            name: "test",
11844            piece_length: 16384,
11845            pieces: &pieces,
11846        };
11847
11848        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
11849        let info_hash = irontide_core::sha1(&info_bytes);
11850        (info_bytes, info_hash)
11851    }
11852
11853    /// Create a magnet-based `TorrentHandle` for testing `PreResolvedMetadata`.
11854    async fn create_magnet_handle(info_hash: Id20) -> TorrentHandle {
11855        let magnet = Magnet {
11856            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
11857            display_name: Some("test".into()),
11858            trackers: vec![],
11859            peers: vec![],
11860            selected_files: None,
11861        };
11862        let config = test_config();
11863        let (atx, amask) = test_alert_channel();
11864        let (dm, _dj) = test_disk_manager();
11865        TorrentHandle::from_magnet(
11866            magnet,
11867            dm,
11868            config,
11869            test_dht_rx(),
11870            test_dht_rx(),
11871            None,
11872            None,
11873            crate::slot_tuner::SlotTuner::disabled(4),
11874            atx,
11875            amask,
11876            None,
11877            None,
11878            test_ban_manager(),
11879            test_ip_filter(),
11880            Arc::new(Vec::new()),
11881            None,
11882            None,
11883            Arc::new(crate::transport::NetworkFactory::tokio()),
11884            None,
11885            Arc::new(crate::stats::SessionCounters::new()),
11886        )
11887        .await
11888        .unwrap()
11889    }
11890
11891    #[tokio::test]
11892    async fn pre_resolved_metadata_applies_when_fetching() {
11893        let (info_bytes, info_hash) = make_test_info_bytes();
11894        let handle = create_magnet_handle(info_hash).await;
11895
11896        // Verify we start in FetchingMetadata state.
11897        let stats = handle.stats().await.unwrap();
11898        assert_eq!(stats.state, TorrentState::FetchingMetadata);
11899
11900        // Send pre-resolved metadata (with a fake peer for pre-seeding).
11901        let peer_addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
11902        handle.send_pre_resolved_metadata(info_bytes, vec![peer_addr]);
11903
11904        // Give the actor time to process the command.
11905        tokio::time::sleep(Duration::from_millis(200)).await;
11906
11907        // Verify transition to Downloading state.
11908        let stats = handle.stats().await.unwrap();
11909        assert_eq!(
11910            stats.state,
11911            TorrentState::Downloading,
11912            "should have transitioned to Downloading after pre-resolved metadata"
11913        );
11914        assert!(
11915            stats.pieces_total > 0,
11916            "should know piece count after metadata resolution"
11917        );
11918
11919        handle.shutdown().await.unwrap();
11920    }
11921
11922    #[tokio::test]
11923    async fn pre_resolved_metadata_ignored_after_resolution() {
11924        // Create a .torrent-based handle (already in Downloading state).
11925        let data = vec![0xAB; 32768];
11926        let meta = make_test_torrent(&data, 16384);
11927        let info_hash = meta.info_hash;
11928        let storage = make_storage(&data, 16384);
11929        let config = test_config();
11930
11931        let (atx, amask) = test_alert_channel();
11932        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
11933        let handle = TorrentHandle::from_torrent(
11934            meta,
11935            irontide_core::TorrentVersion::V1Only,
11936            None,
11937            dh,
11938            dm,
11939            config,
11940            test_dht_rx(),
11941            test_dht_rx(),
11942            None,
11943            None,
11944            crate::slot_tuner::SlotTuner::disabled(4),
11945            atx,
11946            amask,
11947            None,
11948            None,
11949            test_ban_manager(),
11950            test_ip_filter(),
11951            Arc::new(Vec::new()),
11952            None,
11953            None,
11954            Arc::new(crate::transport::NetworkFactory::tokio()),
11955            None,
11956            Arc::new(crate::stats::SessionCounters::new()),
11957        )
11958        .await
11959        .unwrap();
11960
11961        let stats_before = handle.stats().await.unwrap();
11962        assert_eq!(stats_before.state, TorrentState::Downloading);
11963
11964        // Send pre-resolved metadata — should be silently ignored since
11965        // the actor is already past FetchingMetadata.
11966        let (info_bytes, _) = make_test_info_bytes();
11967        handle.send_pre_resolved_metadata(info_bytes, vec![]);
11968
11969        // Give the actor time to process (or ignore) the command.
11970        tokio::time::sleep(Duration::from_millis(100)).await;
11971
11972        // Verify state hasn't changed and no crash occurred.
11973        let stats_after = handle.stats().await.unwrap();
11974        assert_eq!(stats_after.state, TorrentState::Downloading);
11975        assert_eq!(stats_after.pieces_total, stats_before.pieces_total);
11976
11977        handle.shutdown().await.unwrap();
11978    }
11979
11980    #[tokio::test]
11981    async fn pre_resolved_metadata_with_invalid_hash_stays_fetching() {
11982        // Build info bytes with a WRONG info_hash — the SHA1 won't match
11983        // the magnet link's info_hash, so try_assemble_metadata should
11984        // fail verification and the actor should stay in FetchingMetadata.
11985        let (info_bytes, _correct_hash) = make_test_info_bytes();
11986
11987        // Use a different (wrong) info_hash for the magnet.
11988        let wrong_hash = Id20::from_hex("0000000000000000000000000000000000000001").unwrap();
11989        let handle = create_magnet_handle(wrong_hash).await;
11990
11991        let stats = handle.stats().await.unwrap();
11992        assert_eq!(stats.state, TorrentState::FetchingMetadata);
11993
11994        // Send metadata with mismatched hash — should fail verification.
11995        handle.send_pre_resolved_metadata(info_bytes, vec![]);
11996
11997        tokio::time::sleep(Duration::from_millis(200)).await;
11998
11999        // Actor should remain in FetchingMetadata (verification failed).
12000        let stats = handle.stats().await.unwrap();
12001        assert_eq!(
12002            stats.state,
12003            TorrentState::FetchingMetadata,
12004            "should stay in FetchingMetadata when info_hash doesn't match"
12005        );
12006
12007        handle.shutdown().await.unwrap();
12008    }
12009
12010    #[test]
12011    fn initial_queue_depth_is_128() {
12012        use crate::peer_shared::INITIAL_QUEUE_DEPTH;
12013        assert_eq!(INITIAL_QUEUE_DEPTH, 128);
12014    }
12015
12016    // ---- M159: seed mode scheduling-suppression integration test ----
12017
12018    /// End-to-end test that seed mode actually suppresses new block request
12019    /// dispatch at the wire level.
12020    ///
12021    /// 1. Spin up a 2-piece torrent with no downloaded data.
12022    /// 2. Connect a mock seeder that advertises both pieces.
12023    /// 3. Wait for the actor to send at least one `Request` (normal dispatch).
12024    /// 4. Flip `set_seed_mode(true)`.
12025    /// 5. Observe that a `Cancel` is sent for the pending request, and that
12026    ///    no additional `Request` messages arrive within 500 ms.
12027    /// 6. Confirm the stats snapshot reflects `user_seed_mode == true`.
12028    #[tokio::test]
12029    #[allow(
12030        clippy::large_stack_arrays,
12031        reason = "test data buffer passed directly to make_storage"
12032    )]
12033    async fn m159_seed_mode_suppresses_new_requests_on_wire() {
12034        let data = vec![0xAB; 32768]; // 32 KiB
12035        let meta = make_test_torrent(&data, 16384); // 2 pieces
12036        let info_hash = meta.info_hash;
12037        // Leecher has empty storage — wants both pieces.
12038        let storage = make_storage(&[0u8; 32768], 16384);
12039
12040        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12041        let listen_addr = listener.local_addr().unwrap();
12042        let config = TorrentConfig {
12043            listen_port: listen_addr.port(),
12044            ..test_config()
12045        };
12046        drop(listener);
12047
12048        let (atx, amask) = test_alert_channel();
12049        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12050        let handle = TorrentHandle::from_torrent(
12051            meta,
12052            irontide_core::TorrentVersion::V1Only,
12053            None,
12054            dh,
12055            dm,
12056            config,
12057            test_dht_rx(),
12058            test_dht_rx(),
12059            None,
12060            None,
12061            crate::slot_tuner::SlotTuner::disabled(4),
12062            atx,
12063            amask,
12064            None,
12065            None,
12066            test_ban_manager(),
12067            test_ip_filter(),
12068            Arc::new(Vec::new()),
12069            None,
12070            None,
12071            Arc::new(crate::transport::NetworkFactory::tokio()),
12072            None,
12073            Arc::new(crate::stats::SessionCounters::new()),
12074        )
12075        .await
12076        .unwrap();
12077
12078        tokio::time::sleep(Duration::from_millis(50)).await;
12079
12080        // Connect a mock seeder to the actor's listener.
12081        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12082        let (reader, writer) = tokio::io::split(stream);
12083        let mut writer = writer;
12084        let mut reader = reader;
12085
12086        let hs = Handshake::new(
12087            info_hash,
12088            Id20::from_hex("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
12089        );
12090        writer.write_all(&hs.to_bytes()).await.unwrap();
12091        writer.flush().await.unwrap();
12092        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12093        reader.read_exact(&mut hs_buf).await.unwrap();
12094
12095        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12096        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12097
12098        // Drain the actor's ext handshake, then send ours.
12099        let _actor_ext_hs = framed_read.next().await;
12100        let ext_hs = ExtHandshake::new();
12101        let ext_payload = ext_hs.to_bytes().unwrap();
12102        framed_write
12103            .send(Message::Extended {
12104                ext_id: 0,
12105                payload: ext_payload,
12106            })
12107            .await
12108            .unwrap();
12109
12110        // Announce that we (the mock seeder) have both pieces.
12111        let mut bf = Bitfield::new(2);
12112        bf.set(0);
12113        bf.set(1);
12114        framed_write
12115            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12116            .await
12117            .unwrap();
12118        framed_write.send(Message::Unchoke).await.unwrap();
12119
12120        // Wait for the actor to send its first Request (and any adjacent ones
12121        // inside one select tick). This confirms the normal dispatch path is
12122        // engaged before we flip into seed mode.
12123        let mut initial_request_seen = false;
12124        let wait_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12125        loop {
12126            let remaining = wait_deadline.saturating_duration_since(tokio::time::Instant::now());
12127            if remaining.is_zero() {
12128                break;
12129            }
12130            match tokio::time::timeout(remaining, framed_read.next()).await {
12131                Ok(Some(Ok(Message::Request { .. }))) => {
12132                    initial_request_seen = true;
12133                    break;
12134                }
12135                Ok(Some(Ok(_))) => {}
12136                _ => break,
12137            }
12138        }
12139        assert!(
12140            initial_request_seen,
12141            "actor should have sent a Request before seed mode toggle"
12142        );
12143
12144        // Flip user seed mode on. From this point forward the actor must not
12145        // dispatch any new Request messages.
12146        handle.set_seed_mode(true).await.unwrap();
12147
12148        // There's an inherent race between the actor processing the toggle
12149        // and the per-peer requester loop receiving its `DispatchCommand::Stop`
12150        // — a block may already be in the writer's queue when we flip. Drain
12151        // for a brief grace window, then verify the dispatch has fully halted
12152        // for a second longer window: if scheduling is truly suppressed, no
12153        // Request messages will arrive during the steady-state window.
12154        let grace_deadline = tokio::time::Instant::now() + Duration::from_millis(200);
12155        let mut cancel_seen = false;
12156        let mut grace_requests = 0u32;
12157        loop {
12158            let remaining = grace_deadline.saturating_duration_since(tokio::time::Instant::now());
12159            if remaining.is_zero() {
12160                break;
12161            }
12162            match tokio::time::timeout(remaining, framed_read.next()).await {
12163                Ok(Some(Ok(Message::Request { .. }))) => {
12164                    grace_requests += 1;
12165                }
12166                Ok(Some(Ok(Message::Cancel { .. }))) => {
12167                    cancel_seen = true;
12168                }
12169                Ok(Some(Ok(_))) => {}
12170                Ok(None | Some(Err(_))) | Err(_) => break,
12171            }
12172        }
12173        let _ = (cancel_seen, grace_requests);
12174
12175        // Steady-state window: if the dispatch path is really gated, zero
12176        // new Request messages must arrive for the next 500 ms.
12177        let steady_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
12178        let mut steady_requests = 0u32;
12179        loop {
12180            let remaining = steady_deadline.saturating_duration_since(tokio::time::Instant::now());
12181            if remaining.is_zero() {
12182                break;
12183            }
12184            match tokio::time::timeout(remaining, framed_read.next()).await {
12185                Ok(Some(Ok(Message::Request { .. }))) => {
12186                    steady_requests += 1;
12187                }
12188                Ok(Some(Ok(_))) => {}
12189                Ok(None | Some(Err(_))) | Err(_) => break,
12190            }
12191        }
12192
12193        assert_eq!(
12194            steady_requests, 0,
12195            "after the Stop propagation grace window, no new Request messages \
12196             must appear during steady-state while user_seed_mode is active"
12197        );
12198
12199        // Stats should reflect the flag.
12200        let stats = handle.stats().await.unwrap();
12201        assert!(
12202            stats.user_seed_mode,
12203            "stats.user_seed_mode should be true after set_seed_mode(true)"
12204        );
12205
12206        handle.shutdown().await.unwrap();
12207    }
12208
12209    // ---- M159 Task 1: Wire-level test — uploads continue in seed mode ----
12210    //
12211    // The point of user seed mode is to stop *downloading* (suppress new
12212    // block requests we issue to peers) while still *uploading* (honouring
12213    // incoming `Request` messages from peers who want pieces we have).
12214    // The companion test `m159_seed_mode_suppresses_new_requests_on_wire`
12215    // covers the download-suppression half; this one closes the loop by
12216    // asserting that the upload path survives a seed-mode toggle.
12217    //
12218    // Test shape:
12219    //   1. Pre-seed storage with two verified pieces (actor starts in
12220    //      `Seeding` state because `make_seeded_storage` writes the full
12221    //      dataset before the actor runs initial verification).
12222    //   2. Flip `user_seed_mode` on via `set_seed_mode(true)`. This is the
12223    //      load-bearing step — uploads must still work *after* seed mode
12224    //      is enabled.
12225    //   3. Connect a fake leecher via a real `TcpListener`, complete the
12226    //      BT + extended handshake.
12227    //   4. Announce an empty bitfield and send `Interested`. The choker
12228    //      still runs in seed mode, so the actor must respond with
12229    //      `Unchoke` (seed-mode choking algorithms unchoke interested
12230    //      peers based on upload throughput — a brand-new peer that just
12231    //      sent Interested is a valid candidate).
12232    //   5. Send `Request { index: 0, begin: 0, length: 16384 }` and assert
12233    //      a matching `Piece` message arrives on the wire within 2s, with
12234    //      a payload of the correct length and filled with the pre-seeded
12235    //      byte pattern.
12236    #[tokio::test]
12237    async fn m159_seed_mode_uploads_continue_on_wire() {
12238        const FILL_BYTE: u8 = 0x5A;
12239        const PIECE_LENGTH: u64 = 16384;
12240        const TOTAL_LEN: usize = 32768; // 2 pieces
12241
12242        let data = vec![FILL_BYTE; TOTAL_LEN];
12243        let meta = make_test_torrent(&data, PIECE_LENGTH);
12244        let info_hash = meta.info_hash;
12245        // Pre-seeded storage — actor transitions to Seeding after verify.
12246        let storage = make_seeded_storage(&data, PIECE_LENGTH);
12247
12248        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12249        let listen_addr = listener.local_addr().unwrap();
12250        let config = TorrentConfig {
12251            listen_port: listen_addr.port(),
12252            ..test_config()
12253        };
12254        drop(listener);
12255
12256        let (atx, amask) = test_alert_channel();
12257        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
12258        let handle = TorrentHandle::from_torrent(
12259            meta,
12260            irontide_core::TorrentVersion::V1Only,
12261            None,
12262            dh,
12263            dm,
12264            config,
12265            test_dht_rx(),
12266            test_dht_rx(),
12267            None,
12268            None,
12269            crate::slot_tuner::SlotTuner::disabled(4),
12270            atx,
12271            amask,
12272            None,
12273            None,
12274            test_ban_manager(),
12275            test_ip_filter(),
12276            Arc::new(Vec::new()),
12277            None,
12278            None,
12279            Arc::new(crate::transport::NetworkFactory::tokio()),
12280            None,
12281            Arc::new(crate::stats::SessionCounters::new()),
12282        )
12283        .await
12284        .unwrap();
12285
12286        // Wait for initial verification to complete so the actor is really
12287        // in Seeding state before we flip seed mode. Poll stats up to 3s.
12288        let seeding_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12289        loop {
12290            tokio::time::sleep(Duration::from_millis(50)).await;
12291            let stats = handle.stats().await.unwrap();
12292            if stats.state == TorrentState::Seeding && stats.pieces_have == 2 {
12293                break;
12294            }
12295            if tokio::time::Instant::now() > seeding_deadline {
12296                let stats = handle.stats().await.unwrap();
12297                panic!(
12298                    "actor did not reach Seeding state within 3s: state={:?}, have={}/{}",
12299                    stats.state, stats.pieces_have, stats.pieces_total
12300                );
12301            }
12302        }
12303
12304        // Flip user seed mode on. The upload path must continue to serve
12305        // incoming Request messages from this point forward.
12306        handle.set_seed_mode(true).await.unwrap();
12307        let stats = handle.stats().await.unwrap();
12308        assert!(
12309            stats.user_seed_mode,
12310            "stats.user_seed_mode should be true after set_seed_mode(true)"
12311        );
12312
12313        // Connect a mock leecher to the actor's listener.
12314        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
12315        let (reader, writer) = tokio::io::split(stream);
12316        let mut writer = writer;
12317        let mut reader = reader;
12318
12319        let hs = Handshake::new(
12320            info_hash,
12321            Id20::from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap(),
12322        );
12323        writer.write_all(&hs.to_bytes()).await.unwrap();
12324        writer.flush().await.unwrap();
12325        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
12326        reader.read_exact(&mut hs_buf).await.unwrap();
12327
12328        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
12329        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
12330
12331        // Drain the actor's ext handshake, then send ours.
12332        let _actor_ext_hs = framed_read.next().await;
12333        let ext_hs = ExtHandshake::new();
12334        let ext_payload = ext_hs.to_bytes().unwrap();
12335        framed_write
12336            .send(Message::Extended {
12337                ext_id: 0,
12338                payload: ext_payload,
12339            })
12340            .await
12341            .unwrap();
12342
12343        // Tell the actor we (the mock leecher) have nothing.
12344        let bf = Bitfield::new(2);
12345        framed_write
12346            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
12347            .await
12348            .unwrap();
12349        framed_write.send(Message::Interested).await.unwrap();
12350
12351        // Wait for Unchoke from the actor. The actor may also send its own
12352        // Bitfield/Have/Extended/Choke/etc.; we drain non-Unchoke messages
12353        // until we see it (or time out).
12354        let unchoke_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
12355        let mut saw_unchoke = false;
12356        loop {
12357            let remaining = unchoke_deadline.saturating_duration_since(tokio::time::Instant::now());
12358            if remaining.is_zero() {
12359                break;
12360            }
12361            match tokio::time::timeout(remaining, framed_read.next()).await {
12362                Ok(Some(Ok(Message::Unchoke))) => {
12363                    saw_unchoke = true;
12364                    break;
12365                }
12366                Ok(Some(Ok(_))) => {}
12367                Ok(None | Some(Err(_))) => break,
12368                Err(_elapsed) => break,
12369            }
12370        }
12371        assert!(
12372            saw_unchoke,
12373            "actor should have unchoked the leecher while user_seed_mode is active"
12374        );
12375
12376        // Request piece 0, full 16 KiB block. The actor is seeding with
12377        // seed mode on — it must still serve this upload.
12378        framed_write
12379            .send(Message::Request {
12380                index: 0,
12381                begin: 0,
12382                length: PIECE_LENGTH as u32,
12383            })
12384            .await
12385            .unwrap();
12386
12387        // Expect a Piece message to arrive on the wire with matching
12388        // index/begin and the correct payload. Drain any other messages
12389        // (Have, Bitfield updates, Choke refreshes, etc.) that may arrive
12390        // first.
12391        let piece_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
12392        let mut got_piece = false;
12393        loop {
12394            let remaining = piece_deadline.saturating_duration_since(tokio::time::Instant::now());
12395            if remaining.is_zero() {
12396                break;
12397            }
12398            match tokio::time::timeout(remaining, framed_read.next()).await {
12399                Ok(Some(Ok(Message::Piece {
12400                    index,
12401                    begin,
12402                    data_0,
12403                    data_1,
12404                }))) => {
12405                    assert_eq!(index, 0, "Piece index should match request");
12406                    assert_eq!(begin, 0, "Piece begin should match request");
12407                    let mut payload: Vec<u8> =
12408                        Vec::with_capacity(data_0.len().saturating_add(data_1.len()));
12409                    payload.extend_from_slice(&data_0);
12410                    payload.extend_from_slice(&data_1);
12411                    assert_eq!(
12412                        payload.len(),
12413                        PIECE_LENGTH as usize,
12414                        "Piece payload length should match requested length"
12415                    );
12416                    assert!(
12417                        payload.iter().all(|&b| b == FILL_BYTE),
12418                        "Piece payload should contain the pre-seeded fill byte"
12419                    );
12420                    got_piece = true;
12421                    break;
12422                }
12423                Ok(Some(Ok(_))) => {}
12424                Ok(None | Some(Err(_))) => break,
12425                Err(_elapsed) => break,
12426            }
12427        }
12428        assert!(
12429            got_piece,
12430            "actor should have served a Piece in response to Request while user_seed_mode is active"
12431        );
12432
12433        // Stats should still reflect the seed-mode flag and accumulated
12434        // upload bytes for the one block we served.
12435        let stats = handle.stats().await.unwrap();
12436        assert!(
12437            stats.user_seed_mode,
12438            "stats.user_seed_mode should remain true after serving an upload"
12439        );
12440        assert!(
12441            stats.uploaded >= u64::from(PIECE_LENGTH as u32),
12442            "stats.uploaded should reflect the served block, got {}",
12443            stats.uploaded
12444        );
12445
12446        handle.shutdown().await.unwrap();
12447    }
12448
12449    // ---- M161: info dict, v2 hash, and timestamp tests ----
12450
12451    #[tokio::test]
12452    async fn info_field_populated_for_torrent() {
12453        let data = vec![0xAB; 32768];
12454        let meta = make_test_torrent(&data, 16384);
12455        let storage = make_storage(&data, 16384);
12456        let config = test_config();
12457
12458        let (atx, amask) = test_alert_channel();
12459        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12460        let handle = TorrentHandle::from_torrent(
12461            meta,
12462            irontide_core::TorrentVersion::V1Only,
12463            None,
12464            dh,
12465            dm,
12466            config,
12467            test_dht_rx(),
12468            test_dht_rx(),
12469            None,
12470            None,
12471            crate::slot_tuner::SlotTuner::disabled(4),
12472            atx,
12473            amask,
12474            None,
12475            None,
12476            test_ban_manager(),
12477            test_ip_filter(),
12478            Arc::new(Vec::new()),
12479            None,
12480            None,
12481            Arc::new(crate::transport::NetworkFactory::tokio()),
12482            None,
12483            Arc::new(crate::stats::SessionCounters::new()),
12484        )
12485        .await
12486        .unwrap();
12487
12488        tokio::time::sleep(Duration::from_millis(50)).await;
12489
12490        let rd = handle.save_resume_data().await.unwrap();
12491
12492        // info field must be populated when metadata is available
12493        assert!(rd.info.is_some(), "rd.info should be Some for .torrent");
12494
12495        // The embedded bytes must deserialize back to a valid InfoDict
12496        let info_bytes = rd.info.as_ref().unwrap();
12497        let info: irontide_core::InfoDict =
12498            irontide_bencode::from_bytes(info_bytes).expect("info bytes should deserialize");
12499        assert_eq!(info.name, "test");
12500        assert_eq!(info.piece_length, 16384);
12501
12502        handle.shutdown().await.unwrap();
12503    }
12504
12505    #[tokio::test]
12506    async fn info_hash2_none_for_v1_only() {
12507        let data = vec![0xCD; 16384];
12508        let meta = make_test_torrent(&data, 16384);
12509        let storage = make_storage(&data, 16384);
12510        let config = test_config();
12511
12512        let (atx, amask) = test_alert_channel();
12513        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
12514        let handle = TorrentHandle::from_torrent(
12515            meta,
12516            irontide_core::TorrentVersion::V1Only,
12517            None,
12518            dh,
12519            dm,
12520            config,
12521            test_dht_rx(),
12522            test_dht_rx(),
12523            None,
12524            None,
12525            crate::slot_tuner::SlotTuner::disabled(4),
12526            atx,
12527            amask,
12528            None,
12529            None,
12530            test_ban_manager(),
12531            test_ip_filter(),
12532            Arc::new(Vec::new()),
12533            None,
12534            None,
12535            Arc::new(crate::transport::NetworkFactory::tokio()),
12536            None,
12537            Arc::new(crate::stats::SessionCounters::new()),
12538        )
12539        .await
12540        .unwrap();
12541
12542        tokio::time::sleep(Duration::from_millis(50)).await;
12543
12544        let rd = handle.save_resume_data().await.unwrap();
12545
12546        // v1-only torrent must not have a v2 hash
12547        assert!(
12548            rd.info_hash2.is_none(),
12549            "v1-only torrent should have info_hash2 = None"
12550        );
12551
12552        // Timestamps should be populated
12553        assert!(
12554            rd.added_time > 0,
12555            "added_time should be a positive POSIX timestamp"
12556        );
12557
12558        handle.shutdown().await.unwrap();
12559    }
12560
12561    #[tokio::test]
12562    async fn info_none_for_unresolved_magnet() {
12563        let magnet = Magnet {
12564            info_hashes: irontide_core::InfoHashes::v1_only(
12565                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
12566            ),
12567            display_name: Some("magnet-test".into()),
12568            trackers: vec![],
12569            peers: vec![],
12570            selected_files: None,
12571        };
12572        let config = test_config();
12573
12574        let (atx, amask) = test_alert_channel();
12575        let (dm, _dj) = test_disk_manager();
12576        let handle = TorrentHandle::from_magnet(
12577            magnet,
12578            dm,
12579            config,
12580            test_dht_rx(),
12581            test_dht_rx(),
12582            None,
12583            None,
12584            crate::slot_tuner::SlotTuner::disabled(4),
12585            atx,
12586            amask,
12587            None,
12588            None,
12589            test_ban_manager(),
12590            test_ip_filter(),
12591            Arc::new(Vec::new()),
12592            None,
12593            None,
12594            Arc::new(crate::transport::NetworkFactory::tokio()),
12595            None,
12596            Arc::new(crate::stats::SessionCounters::new()),
12597        )
12598        .await
12599        .unwrap();
12600
12601        tokio::time::sleep(Duration::from_millis(50)).await;
12602
12603        let rd = handle.save_resume_data().await.unwrap();
12604
12605        // Unresolved magnet has no metadata, so info must be None
12606        assert!(
12607            rd.info.is_none(),
12608            "unresolved magnet should have info = None"
12609        );
12610
12611        // added_time should still be set even for magnets
12612        assert!(
12613            rd.added_time > 0,
12614            "added_time should be set for magnet links"
12615        );
12616
12617        handle.shutdown().await.unwrap();
12618    }
12619
12620    // ---- v0.173.1: TorrentCommand::GetMeta tests (Class A architectural fix) ----
12621
12622    #[tokio::test]
12623    async fn torrent_command_get_meta_returns_none_before_metadata() {
12624        // v0.173.1: pre-metadata magnet handles must return None from GetMeta.
12625        let info_hash =
12626            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").expect("valid hex");
12627        let handle = create_magnet_handle(info_hash).await;
12628
12629        let (tx, rx) = oneshot::channel();
12630        handle
12631            .cmd_tx
12632            .send(TorrentCommand::GetMeta { reply: tx })
12633            .await
12634            .expect("cmd_tx send");
12635        let result = rx.await.expect("GetMeta reply");
12636        assert!(
12637            result.is_none(),
12638            "pre-metadata magnet must return None from GetMeta"
12639        );
12640
12641        handle.shutdown().await.unwrap();
12642    }
12643
12644    #[tokio::test]
12645    async fn torrent_command_get_meta_returns_some_after_metadata() {
12646        // v0.173.1: once metadata is assembled (via PreResolvedMetadata push),
12647        // GetMeta must return Some(meta) with the matching info hash.
12648        let (info_bytes, info_hash) = make_test_info_bytes();
12649        let handle = create_magnet_handle(info_hash).await;
12650
12651        handle.send_pre_resolved_metadata(info_bytes, vec![]);
12652
12653        // Poll GetMeta until it returns Some or we exceed a 2s budget — the
12654        // PreResolvedMetadata command runs through the actor select! loop
12655        // asynchronously so we can't rely on a hard sleep.
12656        let mut result = None;
12657        for _ in 0..100 {
12658            tokio::time::sleep(Duration::from_millis(20)).await;
12659            let (tx, rx) = oneshot::channel();
12660            handle
12661                .cmd_tx
12662                .send(TorrentCommand::GetMeta { reply: tx })
12663                .await
12664                .expect("cmd_tx send");
12665            let r = rx.await.expect("GetMeta reply");
12666            if r.is_some() {
12667                result = r;
12668                break;
12669            }
12670        }
12671        let meta = result.expect("GetMeta must return Some after PreResolvedMetadata");
12672        assert_eq!(meta.info_hash, info_hash);
12673
12674        handle.shutdown().await.unwrap();
12675    }
12676
12677    // ── M178 Lane B1: WebSeedStats actor state machine ───────────────
12678
12679    #[tokio::test]
12680    async fn web_seed_progress_idle_to_active_on_first_success() {
12681        let mut actor = TorrentActor::for_throttle_test(8, 0);
12682        actor.handle_web_seed_progress("http://seed.example/file", 1024, 1_000_000, None);
12683        let stats = actor
12684            .web_seed_stats
12685            .get("http://seed.example/file")
12686            .expect("stats inserted");
12687        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12688        assert_eq!(stats.downloaded_bytes, 1024);
12689        assert_eq!(stats.last_rate_bps, 1_000_000);
12690        assert_eq!(stats.consecutive_failures, 0);
12691        assert!(stats.last_attempt_unix_secs > 0);
12692        assert!(actor.need_save_resume);
12693    }
12694
12695    #[tokio::test]
12696    async fn web_seed_progress_active_to_errored_then_recovery_persists_last_error() {
12697        let mut actor = TorrentActor::for_throttle_test(8, 0);
12698        let url = "http://seed.example/file".to_string();
12699
12700        // 1) Initial success → Active
12701        actor.handle_web_seed_progress(&url, 1024, 100, None);
12702        assert_eq!(
12703            actor.web_seed_stats[&url].state,
12704            irontide_core::WebSeedState::Active
12705        );
12706
12707        // 2) Failure → Errored, last_error populated
12708        actor.handle_web_seed_progress(&url, 1024, 0, Some("503".into()));
12709        let stats = &actor.web_seed_stats[&url];
12710        assert_eq!(stats.state, irontide_core::WebSeedState::Errored);
12711        assert_eq!(stats.last_error.as_deref(), Some("503"));
12712        assert_eq!(stats.consecutive_failures, 1);
12713
12714        // 3) Recovery → Active, but last_error PERSISTS (Issue 2.2)
12715        actor.handle_web_seed_progress(&url, 2048, 200, None);
12716        let stats = &actor.web_seed_stats[&url];
12717        assert_eq!(stats.state, irontide_core::WebSeedState::Active);
12718        assert_eq!(
12719            stats.last_error.as_deref(),
12720            Some("503"),
12721            "last_error must persist through recovery (D-eng-8)"
12722        );
12723        assert_eq!(
12724            stats.consecutive_failures, 0,
12725            "consecutive_failures resets on success"
12726        );
12727    }
12728
12729    #[tokio::test]
12730    async fn web_seed_progress_consecutive_failures_monotonic_within_run() {
12731        let mut actor = TorrentActor::for_throttle_test(8, 0);
12732        let url = "http://seed.example/file".to_string();
12733
12734        actor.handle_web_seed_progress(&url, 0, 0, Some("e1".into()));
12735        actor.handle_web_seed_progress(&url, 0, 0, Some("e2".into()));
12736        actor.handle_web_seed_progress(&url, 0, 0, Some("e3".into()));
12737        let stats = &actor.web_seed_stats[&url];
12738        assert_eq!(stats.consecutive_failures, 3);
12739        assert_eq!(
12740            stats.last_error.as_deref(),
12741            Some("e3"),
12742            "last_error reflects most recent message"
12743        );
12744
12745        actor.handle_web_seed_progress(&url, 1024, 100, None);
12746        assert_eq!(
12747            actor.web_seed_stats[&url].consecutive_failures, 0,
12748            "success resets consecutive_failures"
12749        );
12750    }
12751
12752    // ── M178 Lane B3: PeX + LSD peer counters ────────────────────────
12753
12754    /// Inject a `PeerStates` into a synthetic actor so `handle_add_peers`
12755    /// can run without spinning up the full peer pipeline.
12756    fn install_peer_states(actor: &mut TorrentActor) {
12757        let (queue_tx, _queue_rx) = mpsc::unbounded_channel();
12758        actor.peer_states = Some(std::sync::Arc::new(crate::peer_states::PeerStates::new(
12759            queue_tx,
12760        )));
12761    }
12762
12763    fn addr(octet: u8, port: u16) -> std::net::SocketAddr {
12764        std::net::SocketAddr::new(
12765            std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, octet)),
12766            port,
12767        )
12768    }
12769
12770    #[tokio::test]
12771    async fn pex_count_dedups_same_peer_in_two_messages() {
12772        let mut actor = TorrentActor::for_throttle_test(8, 0);
12773        install_peer_states(&mut actor);
12774
12775        // Message 1: peers A and B
12776        actor.handle_add_peers(
12777            vec![addr(1, 6881), addr(2, 6881)],
12778            crate::peer_state::PeerSource::Pex,
12779        );
12780        // Message 2: peer A again, plus C
12781        actor.handle_add_peers(
12782            vec![addr(1, 6881), addr(3, 6881)],
12783            crate::peer_state::PeerSource::Pex,
12784        );
12785        assert_eq!(
12786            actor.pex_peer_count, 3,
12787            "3 unique peers across 2 PEX messages, A counted once"
12788        );
12789        assert_eq!(actor.lsd_peer_count, 0, "LSD untouched");
12790    }
12791
12792    #[tokio::test]
12793    async fn lsd_count_aggregates_across_multicasts() {
12794        let mut actor = TorrentActor::for_throttle_test(8, 0);
12795        install_peer_states(&mut actor);
12796
12797        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Lsd);
12798        actor.handle_add_peers(
12799            vec![addr(2, 6881), addr(3, 6881)],
12800            crate::peer_state::PeerSource::Lsd,
12801        );
12802        actor.handle_add_peers(
12803            vec![addr(1, 6881)], // dup
12804            crate::peer_state::PeerSource::Lsd,
12805        );
12806        assert_eq!(actor.lsd_peer_count, 3);
12807    }
12808
12809    #[tokio::test]
12810    async fn other_sources_do_not_bump_pex_or_lsd() {
12811        let mut actor = TorrentActor::for_throttle_test(8, 0);
12812        install_peer_states(&mut actor);
12813
12814        actor.handle_add_peers(
12815            vec![addr(1, 6881), addr(2, 6881)],
12816            crate::peer_state::PeerSource::Tracker,
12817        );
12818        actor.handle_add_peers(vec![addr(3, 6881)], crate::peer_state::PeerSource::Dht);
12819        actor.handle_add_peers(vec![addr(4, 6881)], crate::peer_state::PeerSource::Incoming);
12820        assert_eq!(actor.pex_peer_count, 0);
12821        assert_eq!(actor.lsd_peer_count, 0);
12822    }
12823
12824    #[tokio::test]
12825    async fn dedup_runs_against_global_seen_set() {
12826        // A peer first observed via tracker won't recount when later
12827        // re-announced via PEX, because the seen-set is shared across
12828        // sources. This is the intended behaviour: PEX/LSD counts measure
12829        // *new* peer discoveries from those subsystems, not redundant
12830        // re-announcements.
12831        let mut actor = TorrentActor::for_throttle_test(8, 0);
12832        install_peer_states(&mut actor);
12833
12834        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Tracker);
12835        actor.handle_add_peers(vec![addr(1, 6881)], crate::peer_state::PeerSource::Pex);
12836        assert_eq!(
12837            actor.pex_peer_count, 0,
12838            "peer already seen via tracker — PEX shouldn't re-count"
12839        );
12840    }
12841
12842    #[tokio::test]
12843    async fn web_seed_progress_dirties_resume_flag() {
12844        let mut actor = TorrentActor::for_throttle_test(8, 0);
12845        actor.need_save_resume = false;
12846        actor.handle_web_seed_progress("http://x/file", 100, 50, None);
12847        assert!(
12848            actor.need_save_resume,
12849            "every progress event should mark fast-resume dirty"
12850        );
12851    }
12852
12853    #[tokio::test]
12854    async fn paused_torrent_rejects_outbound_peer_connect() {
12855        let mut actor = TorrentActor::for_throttle_test(8, 0);
12856        install_peer_states(&mut actor);
12857        actor.state = TorrentState::Paused;
12858
12859        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12860        let permit = sem.clone().acquire_owned().await.unwrap();
12861        let connect = crate::peer_adder::ConnectPeer {
12862            addr: addr(1, 6881),
12863            source: crate::peer_state::PeerSource::Dht,
12864            permit,
12865        };
12866        actor.handle_adder_connect(connect);
12867        assert!(
12868            actor.peers.is_empty(),
12869            "paused torrent must not accept outbound peer connections"
12870        );
12871        assert_eq!(
12872            sem.available_permits(),
12873            1,
12874            "semaphore permit must be released on rejection"
12875        );
12876    }
12877
12878    #[tokio::test]
12879    async fn resume_from_queued_restores_fetching_metadata_for_magnets() {
12880        let mut actor = TorrentActor::for_throttle_test(0, 0);
12881        actor.state = TorrentState::Queued;
12882        assert!(
12883            actor.chunk_tracker.is_none(),
12884            "magnet torrent has no chunk tracker before metadata"
12885        );
12886        assert_eq!(actor.num_pieces, 0);
12887
12888        actor.handle_resume().await;
12889        assert_eq!(
12890            actor.state,
12891            TorrentState::FetchingMetadata,
12892            "magnet torrent must resume to FetchingMetadata, not Downloading"
12893        );
12894    }
12895
12896    #[tokio::test]
12897    async fn resume_from_queued_restores_downloading_when_metadata_known() {
12898        let mut actor = TorrentActor::for_throttle_test(8, 0);
12899        actor.state = TorrentState::Queued;
12900
12901        actor.handle_resume().await;
12902        assert_eq!(
12903            actor.state,
12904            TorrentState::Downloading,
12905            "torrent with known pieces must resume to Downloading"
12906        );
12907    }
12908
12909    #[tokio::test]
12910    async fn queued_torrent_rejects_outbound_peer_connect() {
12911        let mut actor = TorrentActor::for_throttle_test(8, 0);
12912        install_peer_states(&mut actor);
12913        actor.state = TorrentState::Queued;
12914
12915        let sem = Arc::new(tokio::sync::Semaphore::new(1));
12916        let permit = sem.clone().acquire_owned().await.unwrap();
12917        let connect = crate::peer_adder::ConnectPeer {
12918            addr: addr(1, 6881),
12919            source: crate::peer_state::PeerSource::Dht,
12920            permit,
12921        };
12922        actor.handle_adder_connect(connect);
12923        assert!(
12924            actor.peers.is_empty(),
12925            "queued torrent must not accept outbound peer connections"
12926        );
12927        assert_eq!(
12928            sem.available_permits(),
12929            1,
12930            "semaphore permit must be released on rejection"
12931        );
12932    }
12933
12934    /// Inject a synthetic `PeerState` directly into `actor.peers` so
12935    /// `disconnect_peer` exercises the flush path without spinning up
12936    /// real peer tasks.
12937    fn inject_peer_for_flush(
12938        actor: &mut TorrentActor,
12939        peer_addr: std::net::SocketAddr,
12940        unchoke_started: Option<std::time::Instant>,
12941        prior_total: std::time::Duration,
12942    ) {
12943        let (cmd_tx, _cmd_rx) = mpsc::channel(8);
12944        let mut peer = crate::peer_state::PeerState::new(
12945            peer_addr,
12946            actor.num_pieces,
12947            cmd_tx,
12948            crate::peer_state::PeerSource::Tracker,
12949            Arc::new(AtomicU32::new(0)),
12950            Arc::new(AtomicU32::new(128)),
12951            Arc::new(tokio::sync::Notify::new()),
12952        );
12953        peer.am_unchoke_started_at = unchoke_started;
12954        peer.unchoke_duration_total = prior_total;
12955        actor.peers.insert(peer_addr, peer);
12956    }
12957
12958    #[tokio::test]
12959    async fn disconnect_while_unchoked_flushes_delta_into_torrent_map() {
12960        let mut actor = TorrentActor::for_throttle_test(8, 0);
12961        let p = addr(1, 6881);
12962
12963        // Seed the peer with an in-flight unchoke window opened ~50 ms ago
12964        // and a pre-existing 100 ms accumulator from prior toggles.
12965        inject_peer_for_flush(
12966            &mut actor,
12967            p,
12968            Some(std::time::Instant::now() - std::time::Duration::from_millis(50)),
12969            std::time::Duration::from_millis(100),
12970        );
12971
12972        actor.disconnect_peer(p, "test");
12973
12974        let total = actor
12975            .unchoke_durations
12976            .get(&p)
12977            .copied()
12978            .expect("disconnect must flush a non-zero delta into the torrent map");
12979        assert!(
12980            total >= std::time::Duration::from_millis(140),
12981            "expected ≥140 ms (100 prior + ~50 in-flight), got {total:?}"
12982        );
12983    }
12984
12985    #[tokio::test]
12986    async fn disconnect_then_reconnect_preserves_history() {
12987        let mut actor = TorrentActor::for_throttle_test(8, 0);
12988        let p = addr(2, 6881);
12989
12990        // First connection: 80 ms unchoke window already accumulated.
12991        inject_peer_for_flush(&mut actor, p, None, std::time::Duration::from_millis(80));
12992        actor.disconnect_peer(p, "test");
12993        let after_first = *actor
12994            .unchoke_durations
12995            .get(&p)
12996            .expect("first flush must populate the entry");
12997        assert_eq!(after_first, std::time::Duration::from_millis(80));
12998
12999        // Reconnect: peer rejoins with a fresh in-flight window.
13000        inject_peer_for_flush(
13001            &mut actor,
13002            p,
13003            Some(std::time::Instant::now() - std::time::Duration::from_millis(40)),
13004            std::time::Duration::ZERO,
13005        );
13006        actor.disconnect_peer(p, "test");
13007        let after_second = *actor.unchoke_durations.get(&p).unwrap();
13008        assert!(
13009            after_second >= std::time::Duration::from_millis(120),
13010            "second flush must add to the existing entry, got {after_second:?}"
13011        );
13012    }
13013
13014    // -- M187 Fix B: piece-verified wakes reservation_notify --
13015
13016    #[tokio::test]
13017    async fn piece_verified_wakes_reservation_notify() {
13018        let mut actor = TorrentActor::for_throttle_test(8, 0);
13019        let notify = Arc::new(tokio::sync::Notify::new());
13020        actor.reservation_notify = Some(Arc::clone(&notify));
13021
13022        let notified = notify.notified();
13023        tokio::pin!(notified);
13024        assert!(
13025            futures::poll!(&mut notified).is_pending(),
13026            "notify should not have fired yet"
13027        );
13028
13029        actor.on_piece_verified(0).await;
13030
13031        tokio::time::timeout(Duration::from_secs(1), notified)
13032            .await
13033            .expect("reservation_notify must be woken by on_piece_verified");
13034    }
13035
13036    // -- 2026-05-11 state-gated pipeline-tick safety-net wake --
13037
13038    /// Helper: construct an actor that already has a `PieceTracker` with the
13039    /// given (`queue_count`, `inflight_count`). The tracker starts empty and
13040    /// we mark pieces wanted/reserved as needed to land on the target shape.
13041    fn actor_with_tracker_state(queue: u32, inflight: u32) -> TorrentActor {
13042        use crate::piece_reservation::PieceTracker;
13043        use irontide_storage::Bitfield;
13044        let mut actor = TorrentActor::for_throttle_test(8, 0);
13045        let num_pieces = queue + inflight + 1;
13046        let we_have = Bitfield::new(num_pieces);
13047        let mut wanted = Bitfield::new(num_pieces);
13048        for i in 0..num_pieces {
13049            wanted.set(i);
13050        }
13051        let mut pt = PieceTracker::new(num_pieces, &we_have, &wanted);
13052        // Trim the queue down to `queue` (the rest become "completed" by
13053        // marking them unwanted, which clears them from queue_pieces).
13054        for i in queue..num_pieces {
13055            pt.mark_unwanted(i);
13056        }
13057        // Move `inflight` pieces from queue to inflight via record_reservation.
13058        for i in 0..inflight {
13059            pt.record_reservation(i, "10.0.0.1:6881".parse().unwrap());
13060        }
13061        // After this: queue_count() == queue - inflight, inflight_count() == inflight.
13062        // We started with `queue` wanted pieces, then reserved `inflight` of
13063        // them, leaving (queue - inflight) in the queue. Adjust caller-facing
13064        // semantics so the helper's name matches the assertion.
13065        actor.piece_tracker = Some(pt);
13066        actor
13067    }
13068
13069    #[tokio::test]
13070    async fn pipeline_tick_skips_wake_when_dispatch_state_unchanged() {
13071        let mut actor = actor_with_tracker_state(10, 3);
13072        let notify = Arc::new(tokio::sync::Notify::new());
13073        actor.reservation_notify = Some(Arc::clone(&notify));
13074
13075        // First tick seeds the baseline and always wakes — this matches the
13076        // helper's documented first-call semantics. Drop the baseline wake
13077        // by polling once before installing the real test waiter.
13078        actor.tick_dispatch_safety_wake();
13079        let _drain = notify.notified();
13080
13081        // No dispatch state change between this tick and the next.
13082        let notified = notify.notified();
13083        tokio::pin!(notified);
13084        actor.tick_dispatch_safety_wake();
13085
13086        // Give tokio a chance to dispatch any pending wakes before asserting.
13087        tokio::task::yield_now().await;
13088        assert!(
13089            futures::poll!(&mut notified).is_pending(),
13090            "tick must not wake when (queue_count, inflight_count) is unchanged"
13091        );
13092        // And the skip counter increments.
13093        let skipped = actor.counters.get(crate::stats::DISPATCH_TICK_WAKE_SKIPPED);
13094        assert!(
13095            skipped >= 1,
13096            "expected DISPATCH_TICK_WAKE_SKIPPED >= 1, got {skipped}"
13097        );
13098    }
13099
13100    #[tokio::test]
13101    async fn pipeline_tick_wakes_when_inflight_changes() {
13102        let mut actor = actor_with_tracker_state(10, 3);
13103        let notify = Arc::new(tokio::sync::Notify::new());
13104        actor.reservation_notify = Some(Arc::clone(&notify));
13105
13106        // Seed baseline.
13107        actor.tick_dispatch_safety_wake();
13108
13109        // Mutate dispatch state: reserve another piece via the tracker. This
13110        // changes both queue_count (down 1) and inflight_count (up 1).
13111        if let Some(ref mut pt) = actor.piece_tracker {
13112            pt.record_reservation(5, "10.0.0.2:6881".parse().unwrap());
13113        }
13114
13115        let notified = notify.notified();
13116        tokio::pin!(notified);
13117        actor.tick_dispatch_safety_wake();
13118
13119        tokio::time::timeout(Duration::from_secs(1), notified)
13120            .await
13121            .expect("tick must wake when dispatch state changed");
13122    }
13123}