Skip to main content

irontide_session/
session.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_precision_loss,
4    clippy::cast_possible_wrap,
5    clippy::cast_sign_loss,
6    clippy::unchecked_time_subtraction,
7    reason = "M175: session-level counters/rates bounded by torrent count; time deltas use post-init Instants"
8)]
9
10//! `SessionHandle` / `SessionActor` — multi-torrent session manager.
11//!
12//! Actor model: `SessionHandle` is the cloneable public API (mpsc sender),
13//! `SessionActor` is the single-owner event loop (internal).
14
15use std::collections::HashMap;
16use std::net::{IpAddr, SocketAddr};
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21use dashmap::DashMap;
22use tokio::sync::{broadcast, mpsc, oneshot};
23
24use tracing::{debug, info, warn};
25
26use irontide_core::{DEFAULT_CHUNK_SIZE, Id20, Lengths, Magnet};
27use irontide_dht::DhtHandle;
28use irontide_storage::TorrentStorage;
29
30use crate::alert::{Alert, AlertCategory, AlertKind, AlertStream, post_alert};
31use crate::settings::Settings;
32use crate::torrent::TorrentHandle;
33use crate::types::{
34    FileInfo, SessionStats, TorrentConfig, TorrentInfo, TorrentState, TorrentStats, TorrentSummary,
35};
36
37/// Shared global rate limiter bucket.
38type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
39
40/// Function signature for queue move operations (`move_up`, `move_down`, etc.).
41type QueueMoveFn = fn(&mut [crate::queue::QueueEntry], Id20) -> Vec<(Id20, i32, i32)>;
42
43/// Shared session-wide ban manager, accessed by `TorrentActors` via `Arc`.
44pub(crate) type SharedBanManager = Arc<parking_lot::RwLock<crate::ban::BanManager>>;
45
46/// Shared session-wide IP filter, accessed by `TorrentActors` via `Arc`.
47pub(crate) type SharedIpFilter = Arc<parking_lot::RwLock<crate::ip_filter::IpFilter>>;
48
49/// Result of loading resume state from disk (M161 Phase 4).
50#[derive(Debug, Clone)]
51pub struct ResumeLoadResult {
52    /// Number of torrents successfully restored.
53    pub restored: usize,
54    /// Number of resume files skipped (duplicate, already exists).
55    pub skipped: usize,
56    /// Number of resume files that failed to load.
57    pub failed: usize,
58}
59
60/// Source for a torrent add (M170).
61///
62/// Separated from [`AddTorrentParams`] so that the builder API can name
63/// the source independently of the other knobs.
64#[derive(Debug, Clone)]
65pub enum AddSource {
66    /// Magnet URI (BEP 9 metadata fetch required post-add).
67    Magnet(String),
68    /// Raw `.torrent` file bytes (auto-detects v1/v2/hybrid).
69    Bytes(Vec<u8>),
70}
71
72/// Unified parameters for [`SessionHandle::add_torrent`] (M170).
73///
74/// Replaces the ad-hoc set of `add_magnet`, `add_magnet_uri`,
75/// `add_torrent_bytes` call shapes with a single params struct. Callers
76/// build the struct via the static constructors ([`magnet`] or [`bytes`])
77/// and chain `.with_category()` / `.with_tags()` / `.with_download_dir()`
78/// / `.paused()`.
79///
80/// Download-dir resolution (see `add_torrent`):
81/// 1. `download_dir: Some(p)` wins, if set.
82/// 2. Else, if `category: Some(name)` and the registry contains `name`,
83///    the registry's `save_path` is used.
84/// 3. Else, if `category: Some(name)` and the registry does NOT contain
85///    `name`, the call fails with [`Error::CategoryNotFound`].
86/// 4. Else, falls back to `Settings.download_dir`.
87///
88/// `skip_checking` is reserved for M171+ (qBt `skip_hash_check=true`).
89///
90/// [`magnet`]: Self::magnet
91/// [`bytes`]: Self::bytes
92#[derive(Debug, Clone)]
93pub struct AddTorrentParams {
94    /// The torrent source (magnet URI or raw .torrent bytes).
95    pub source: AddSource,
96    /// Optional qBt-compat category label resolved via the session's
97    /// [`CategoryRegistry`](crate::CategoryRegistry) at add-time.
98    pub category: Option<String>,
99    /// M171: Per-torrent tags baked in at add time (qBt-compat). Multi-
100    /// valued. An empty vec means "no tags" and is the default.
101    pub tags: Vec<String>,
102    /// Explicit download directory, overrides both category lookup and
103    /// `Settings.download_dir` when `Some`.
104    pub download_dir: Option<PathBuf>,
105    /// Whether the torrent should be added in a paused state. `None` (the
106    /// constructor default) means "use [`Settings::default_add_paused`]";
107    /// `Some(v)` is an explicit per-call override. The resolution happens
108    /// in `dispatch_add_torrent_m170` before the M170 post-add hooks run,
109    /// so the actor sees a concrete `bool` either way.
110    pub paused: Option<bool>,
111    /// Reserved for M171+ — skip the initial re-hash on add.
112    pub skip_checking: bool,
113}
114
115impl AddTorrentParams {
116    /// Build a magnet-source add with default knobs.
117    #[must_use]
118    pub fn magnet(uri: impl Into<String>) -> Self {
119        Self {
120            source: AddSource::Magnet(uri.into()),
121            category: None,
122            tags: Vec::new(),
123            download_dir: None,
124            paused: None,
125            skip_checking: false,
126        }
127    }
128
129    /// Build a bytes-source add with default knobs.
130    #[must_use]
131    pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
132        Self {
133            source: AddSource::Bytes(data.into()),
134            category: None,
135            tags: Vec::new(),
136            download_dir: None,
137            paused: None,
138            skip_checking: false,
139        }
140    }
141
142    /// Assign a category label to the torrent. The session resolves the
143    /// name against its registry at add-time; unknown names error out.
144    #[must_use]
145    pub fn with_category(mut self, name: impl Into<String>) -> Self {
146        self.category = Some(name.into());
147        self
148    }
149
150    /// M171: Attach tags at add time. Tags are baked into the torrent's
151    /// config before `TorrentActor::new`, so the first `stats()` call
152    /// returns them — no post-add spawn race.
153    #[must_use]
154    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
155        self.tags = tags;
156        self
157    }
158
159    /// Override the download directory for this torrent.
160    #[must_use]
161    pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
162        self.download_dir = Some(dir.into());
163        self
164    }
165
166    /// Toggle the paused-at-add flag. Wraps the explicit choice in `Some(_)`
167    /// so the actor can distinguish "caller explicitly set this" from
168    /// "caller did not touch it" (the latter falls back to
169    /// [`Settings::default_add_paused`]).
170    #[must_use]
171    pub fn paused(mut self, paused: bool) -> Self {
172        self.paused = Some(paused);
173        self
174    }
175
176    /// Toggle the skip-initial-check flag (M171+).
177    #[must_use]
178    pub fn skip_checking(mut self, skip: bool) -> Self {
179        self.skip_checking = skip;
180        self
181    }
182}
183
184/// Entry for a torrent managed by the session.
185///
186/// v0.173.1: the `meta: Option<TorrentMetaV1>` field was deleted. It was a
187/// stale cache that was silently `None` forever for magnet-added torrents —
188/// see the Class A archaeology in
189/// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`. Four reader
190/// sites (`handle_torrent_info`, `handle_remove_torrent_with_files`, `is_private`,
191/// `handle_ssl_incoming`) now query the `TorrentActor` via `handle.get_meta()`,
192/// which is the single source of truth for torrent metadata.
193struct TorrentEntry {
194    handle: TorrentHandle,
195    /// Queue position (-1 = not queued / not auto-managed).
196    queue_position: i32,
197    /// Whether the queue system controls this torrent.
198    auto_managed: bool,
199    /// When the torrent was last started/resumed (for startup grace period).
200    started_at: Option<tokio::time::Instant>,
201    /// EWMA-smoothed download rate for queue inactive classification.
202    smoothed_download_rate: f64,
203    /// EWMA-smoothed upload rate for queue inactive classification.
204    smoothed_upload_rate: f64,
205}
206
207/// M223 — off-actor add-torrent result bundle.
208///
209/// `handle_add_torrent` (and the M170 path) split into two phases:
210/// 1. **Prepare** — disk register, actor spawn. Runs in a `tokio::spawn`
211///    task off the `SessionActor` recv loop, so concurrent adds do not
212///    serialise the actor's per-command queue.
213/// 2. **Commit** — mutating fixup on the actor: insert into
214///    `self.torrents`, info-hash registry, queue position, alert, LSD
215///    announce. Runs on the actor in response to a `CommitAddTorrent`
216///    feedback command.
217///
218/// This bundle is the success-path payload of the prepare phase; the
219/// commit phase consumes it and produces the caller-visible `Id20`.
220/// `is_private` is precomputed from `meta.info.private` so the commit
221/// arm needs no async query to honour BEP 27 (LSD must skip private
222/// torrents).
223struct PreparedAddTorrent {
224    handle: TorrentHandle,
225    info_hash: Id20,
226    is_private: bool,
227    /// M170 post-add hooks (category label + paused-on-add). `None` for
228    /// the legacy `AddTorrent` path; `Some` only for the
229    /// `AddTorrentM170` path which carries `AddTorrentParams`.
230    m170_post: Option<M170PostAdd>,
231}
232
233/// M223 — M170 post-add side-effects deferred from the recv arm to the
234/// commit arm. Both are applied via `apply_post_add_m170` after the
235/// torrent is inserted into `self.torrents`.
236struct M170PostAdd {
237    category: Option<String>,
238    paused: bool,
239}
240
241/// M223 — snapshot of session state needed by the off-actor add-torrent
242/// prep phase. Built synchronously on the actor at the recv arm via
243/// `SessionActor::build_add_torrent_prep_bundle`; consumed by the
244/// spawned task that runs the heavy `disk_manager.register_torrent` +
245/// `TorrentHandle::from_torrent` work without blocking the actor's
246/// command queue.
247struct AddTorrentPrepBundle {
248    torrent_meta: irontide_core::TorrentMeta,
249    storage_override: Option<Arc<dyn TorrentStorage>>,
250    torrent_config: TorrentConfig,
251    disk_manager: crate::disk::DiskManagerHandle,
252    dht_v4_broadcast: irontide_dht::DhtBroadcast,
253    dht_v6_broadcast: irontide_dht::DhtBroadcast,
254    global_up: Option<SharedBucket>,
255    global_down: Option<SharedBucket>,
256    slot_tuner: crate::slot_tuner::SlotTuner,
257    alert_tx: broadcast::Sender<Alert>,
258    alert_mask: Arc<AtomicU32>,
259    utp_socket: Option<irontide_utp::UtpSocket>,
260    utp_socket_v6: Option<irontide_utp::UtpSocket>,
261    ban_manager: SharedBanManager,
262    ip_filter: SharedIpFilter,
263    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
264    sam_session: Option<Arc<crate::i2p::SamSession>>,
265    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
266    factory: Arc<crate::transport::NetworkFactory>,
267    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
268    counters: Arc<crate::stats::SessionCounters>,
269    m170_post: Option<M170PostAdd>,
270}
271
272impl TorrentEntry {
273    /// Returns `true` if this torrent has the private flag set (BEP 27).
274    ///
275    /// v0.173.1: now queries the `TorrentActor` for current metadata. Magnet
276    /// torrents previously silently returned `false` because the session
277    /// cache (`TorrentEntry.meta`) was permanently `None` for them — BEP 27
278    /// enforcement was bypassed and peer IPs leaked to DHT/LSD. This method
279    /// returning `false` pre-metadata is still correct per the plan's
280    /// Failure-Modes table: the info dict doesn't exist yet, so the flag
281    /// cannot be enforced. Once metadata resolves, subsequent calls see the
282    /// real `info.private` value.
283    async fn is_private(&self) -> bool {
284        match self.handle.get_meta().await {
285            Ok(Some(meta)) => meta.info.private == Some(1),
286            // Pre-metadata or actor shut down: treat as non-private. A magnet
287            // that hasn't resolved yet can't enforce privacy per BEP 27 — the
288            // flag lives in the info dict, which doesn't exist yet.
289            _ => false,
290        }
291    }
292}
293
294/// Commands sent from `SessionHandle` to `SessionActor`.
295enum SessionCommand {
296    AddTorrent {
297        meta: Box<irontide_core::TorrentMeta>,
298        storage: Option<Arc<dyn TorrentStorage>>,
299        download_dir: Option<PathBuf>,
300        reply: oneshot::Sender<crate::Result<Id20>>,
301    },
302    /// M223 — internal feedback variant. Carries the result of off-actor
303    /// `handle_add_torrent` work (TCP bind + disk register + actor spawn)
304    /// back to the actor's recv loop for the mutating fixup
305    /// (insert into `self.torrents` + queue position + alert + LSD).
306    /// Decouples the actor recv loop from per-add latency so the
307    /// parallel-7 POST tail does not stack linearly with already-added
308    /// torrents. Not part of the public `SessionHandle` API — only
309    /// `add_torrent_via_spawn` (the recv-arm helper) emits this variant.
310    CommitAddTorrent {
311        result: crate::Result<PreparedAddTorrent>,
312        reply: oneshot::Sender<crate::Result<Id20>>,
313    },
314    AddMagnet {
315        magnet: Magnet,
316        download_dir: Option<PathBuf>,
317        reply: oneshot::Sender<crate::Result<Id20>>,
318    },
319    RemoveTorrent {
320        info_hash: Id20,
321        reply: oneshot::Sender<crate::Result<()>>,
322    },
323    PauseTorrent {
324        info_hash: Id20,
325        reply: oneshot::Sender<crate::Result<()>>,
326    },
327    ResumeTorrent {
328        info_hash: Id20,
329        reply: oneshot::Sender<crate::Result<()>>,
330    },
331    ForceResumeTorrent {
332        info_hash: Id20,
333        reply: oneshot::Sender<crate::Result<()>>,
334    },
335    SetTorrentSeedRatio {
336        info_hash: Id20,
337        limit: Option<f64>,
338        reply: oneshot::Sender<crate::Result<()>>,
339    },
340    TorrentStats {
341        info_hash: Id20,
342        reply: oneshot::Sender<crate::Result<TorrentStats>>,
343    },
344    TorrentInfo {
345        info_hash: Id20,
346        reply: oneshot::Sender<crate::Result<TorrentInfo>>,
347    },
348    ListTorrents {
349        reply: oneshot::Sender<Vec<Id20>>,
350    },
351    SessionStats {
352        reply: oneshot::Sender<SessionStats>,
353    },
354    SaveTorrentResumeData {
355        info_hash: Id20,
356        reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
357    },
358    SaveSessionState {
359        reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
360    },
361    /// Load and restore torrents from per-torrent resume files on disk (M161).
362    LoadResumeState {
363        reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
364    },
365    QueuePosition {
366        info_hash: Id20,
367        reply: oneshot::Sender<crate::Result<i32>>,
368    },
369    SetQueuePosition {
370        info_hash: Id20,
371        pos: i32,
372        reply: oneshot::Sender<crate::Result<()>>,
373    },
374    QueuePositionUp {
375        info_hash: Id20,
376        reply: oneshot::Sender<crate::Result<()>>,
377    },
378    QueuePositionDown {
379        info_hash: Id20,
380        reply: oneshot::Sender<crate::Result<()>>,
381    },
382    QueuePositionTop {
383        info_hash: Id20,
384        reply: oneshot::Sender<crate::Result<()>>,
385    },
386    QueuePositionBottom {
387        info_hash: Id20,
388        reply: oneshot::Sender<crate::Result<()>>,
389    },
390    BanPeer {
391        ip: IpAddr,
392        reply: oneshot::Sender<()>,
393    },
394    UnbanPeer {
395        ip: IpAddr,
396        reply: oneshot::Sender<bool>,
397    },
398    BannedPeers {
399        reply: oneshot::Sender<Vec<IpAddr>>,
400    },
401    SetIpFilter {
402        filter: crate::ip_filter::IpFilter,
403        reply: oneshot::Sender<()>,
404    },
405    GetIpFilter {
406        reply: oneshot::Sender<crate::ip_filter::IpFilter>,
407    },
408    GetSettings {
409        reply: oneshot::Sender<Settings>,
410    },
411    ApplySettings {
412        settings: Box<Settings>,
413        reply: oneshot::Sender<crate::Result<()>>,
414    },
415    MoveTorrentStorage {
416        info_hash: Id20,
417        new_path: std::path::PathBuf,
418        reply: oneshot::Sender<crate::Result<()>>,
419    },
420    AddPeers {
421        info_hash: Id20,
422        peers: Vec<SocketAddr>,
423        source: crate::peer_state::PeerSource,
424        reply: oneshot::Sender<crate::Result<()>>,
425    },
426    OpenFile {
427        info_hash: Id20,
428        file_index: usize,
429        reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
430    },
431    ForceReannounce {
432        info_hash: Id20,
433        reply: oneshot::Sender<crate::Result<()>>,
434    },
435    TrackerList {
436        info_hash: Id20,
437        reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
438    },
439    /// M178 Lane B3 / TODO-2: `(pex_peer_count, lsd_peer_count)` for the
440    /// given torrent. Used by qBt v2 trackers endpoint + GUI Trackers tab
441    /// to populate the PeX/LSD pseudo-tracker rows with real counts.
442    GetPeerSourceCounts {
443        info_hash: Id20,
444        reply: oneshot::Sender<crate::Result<(usize, usize)>>,
445    },
446    /// Per-peer cumulative unchoke duration for a torrent. `None` reply
447    /// when the torrent does not exist (explicit contract — distinguishes
448    /// "torrent missing" from "torrent exists with empty map").
449    QueryUnchokeDurations {
450        info_hash: Id20,
451        reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
452    },
453    /// M178 Lane C: per-URL web-seed stats for the qBt v2 webseeds endpoint
454    /// and the GUI HTTP Sources tab.
455    GetWebSeedStats {
456        info_hash: Id20,
457        reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
458    },
459    Scrape {
460        info_hash: Id20,
461        reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
462    },
463    SetFilePriority {
464        info_hash: Id20,
465        index: usize,
466        priority: irontide_core::FilePriority,
467        reply: oneshot::Sender<crate::Result<()>>,
468    },
469    FilePriorities {
470        info_hash: Id20,
471        reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
472    },
473    SetDownloadLimit {
474        info_hash: Id20,
475        bytes_per_sec: u64,
476        reply: oneshot::Sender<crate::Result<()>>,
477    },
478    SetUploadLimit {
479        info_hash: Id20,
480        bytes_per_sec: u64,
481        reply: oneshot::Sender<crate::Result<()>>,
482    },
483    DownloadLimit {
484        info_hash: Id20,
485        reply: oneshot::Sender<crate::Result<u64>>,
486    },
487    UploadLimit {
488        info_hash: Id20,
489        reply: oneshot::Sender<crate::Result<u64>>,
490    },
491    SetSequentialDownload {
492        info_hash: Id20,
493        enabled: bool,
494        reply: oneshot::Sender<crate::Result<()>>,
495    },
496    IsSequentialDownload {
497        info_hash: Id20,
498        reply: oneshot::Sender<crate::Result<bool>>,
499    },
500    SetSuperSeeding {
501        info_hash: Id20,
502        enabled: bool,
503        reply: oneshot::Sender<crate::Result<()>>,
504    },
505    IsSuperSeeding {
506        info_hash: Id20,
507        reply: oneshot::Sender<crate::Result<bool>>,
508    },
509    /// Enable or disable user-requested seed-only mode for a torrent (M159).
510    SetSeedMode {
511        info_hash: Id20,
512        enabled: bool,
513        reply: oneshot::Sender<crate::Result<()>>,
514    },
515    AddTracker {
516        info_hash: Id20,
517        url: String,
518        reply: oneshot::Sender<crate::Result<()>>,
519    },
520    ReplaceTrackers {
521        info_hash: Id20,
522        urls: Vec<String>,
523        reply: oneshot::Sender<crate::Result<()>>,
524    },
525    /// Trigger a full piece verification (force recheck) for a torrent.
526    ForceRecheck {
527        info_hash: Id20,
528        reply: oneshot::Sender<crate::Result<()>>,
529    },
530    /// Rename a file within a torrent on disk.
531    RenameFile {
532        info_hash: Id20,
533        file_index: usize,
534        new_name: String,
535        reply: oneshot::Sender<crate::Result<()>>,
536    },
537    /// Set per-torrent maximum connections (0 = use global default).
538    SetMaxConnections {
539        info_hash: Id20,
540        limit: usize,
541        reply: oneshot::Sender<crate::Result<()>>,
542    },
543    /// Get per-torrent maximum connection limit.
544    MaxConnections {
545        info_hash: Id20,
546        reply: oneshot::Sender<crate::Result<usize>>,
547    },
548    /// Set per-torrent maximum upload slots (unchoke slots).
549    SetMaxUploads {
550        info_hash: Id20,
551        limit: usize,
552        reply: oneshot::Sender<crate::Result<()>>,
553    },
554    /// Get per-torrent maximum upload slots (unchoke slots).
555    MaxUploads {
556        info_hash: Id20,
557        reply: oneshot::Sender<crate::Result<usize>>,
558    },
559    /// Get per-peer details for all connected peers of a torrent.
560    GetPeerInfo {
561        info_hash: Id20,
562        reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
563    },
564    /// Get in-flight piece download status for a torrent.
565    GetDownloadQueue {
566        info_hash: Id20,
567        reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
568    },
569    /// Check whether a specific piece has been downloaded.
570    HavePiece {
571        info_hash: Id20,
572        index: u32,
573        reply: oneshot::Sender<crate::Result<bool>>,
574    },
575    /// Get per-piece availability counts from connected peers.
576    PieceAvailability {
577        info_hash: Id20,
578        reply: oneshot::Sender<crate::Result<Vec<u32>>>,
579    },
580    /// Get per-file bytes-downloaded progress.
581    FileProgress {
582        info_hash: Id20,
583        reply: oneshot::Sender<crate::Result<Vec<u64>>>,
584    },
585    /// Get the torrent's identity hashes (v1 and/or v2).
586    InfoHashesQuery {
587        info_hash: Id20,
588        reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
589    },
590    /// Get the full v1 metainfo for a torrent.
591    TorrentFile {
592        info_hash: Id20,
593        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
594    },
595    /// Get the full v2 metainfo for a torrent.
596    TorrentFileV2 {
597        info_hash: Id20,
598        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
599    },
600    /// Force an immediate DHT announce for a torrent.
601    ForceDhtAnnounce {
602        info_hash: Id20,
603        reply: oneshot::Sender<crate::Result<()>>,
604    },
605    /// Force an immediate LSD announce for a torrent (session-level only).
606    ForceLsdAnnounce {
607        info_hash: Id20,
608        reply: oneshot::Sender<crate::Result<()>>,
609    },
610    /// Read all data for a specific piece from disk.
611    ReadPiece {
612        info_hash: Id20,
613        index: u32,
614        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
615    },
616    /// Flush the disk write cache for a torrent.
617    FlushCache {
618        info_hash: Id20,
619        reply: oneshot::Sender<crate::Result<()>>,
620    },
621    /// Check if a torrent handle is still valid (torrent exists and channel open).
622    IsValid {
623        info_hash: Id20,
624        reply: oneshot::Sender<bool>,
625    },
626    /// Clear error state on a torrent.
627    ClearError {
628        info_hash: Id20,
629        reply: oneshot::Sender<crate::Result<()>>,
630    },
631    /// Get per-file open/mode status for a torrent.
632    FileStatus {
633        info_hash: Id20,
634        reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
635    },
636    /// Read the current torrent flags.
637    Flags {
638        info_hash: Id20,
639        reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
640    },
641    /// Set (enable) the specified torrent flags.
642    SetFlags {
643        info_hash: Id20,
644        flags: crate::types::TorrentFlags,
645        reply: oneshot::Sender<crate::Result<()>>,
646    },
647    /// Unset (disable) the specified torrent flags.
648    UnsetFlags {
649        info_hash: Id20,
650        flags: crate::types::TorrentFlags,
651        reply: oneshot::Sender<crate::Result<()>>,
652    },
653    /// Immediately initiate a peer connection for a torrent.
654    ConnectPeer {
655        info_hash: Id20,
656        addr: SocketAddr,
657        reply: oneshot::Sender<crate::Result<()>>,
658    },
659    DhtPutImmutable {
660        value: Vec<u8>,
661        reply: oneshot::Sender<crate::Result<Id20>>,
662    },
663    DhtGetImmutable {
664        target: Id20,
665        reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
666    },
667    DhtPutMutable {
668        keypair_bytes: [u8; 32],
669        value: Vec<u8>,
670        seq: i64,
671        salt: Vec<u8>,
672        reply: oneshot::Sender<crate::Result<Id20>>,
673    },
674    #[allow(clippy::type_complexity)]
675    DhtGetMutable {
676        public_key: [u8; 32],
677        salt: Vec<u8>,
678        reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
679    },
680    /// Save per-torrent resume files for all dirty torrents (M161).
681    SaveResumeState {
682        reply: oneshot::Sender<crate::Result<usize>>,
683    },
684    /// Trigger an immediate session stats snapshot and alert (M50).
685    PostSessionStats,
686    // ── M170: qBt v2 *arr-minimal surface ──
687    /// Unified add entry (M170) — see `AddTorrentParams`.
688    AddTorrentM170 {
689        params: Box<AddTorrentParams>,
690        reply: oneshot::Sender<crate::Result<Id20>>,
691    },
692    /// Create a new category with the given `save_path`.
693    CreateCategory {
694        name: String,
695        save_path: PathBuf,
696        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
697    },
698    /// Update the `save_path` on an existing category.
699    EditCategory {
700        name: String,
701        save_path: PathBuf,
702        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
703    },
704    /// Remove zero or more categories. Returns names actually removed.
705    RemoveCategories {
706        names: Vec<String>,
707        reply: oneshot::Sender<Vec<String>>,
708    },
709    /// Snapshot the current category list.
710    ListCategories {
711        reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
712    },
713    /// Create a batch of tags (M171). One reply slot per requested name
714    /// so the caller can tell which were newly created vs already-present.
715    CreateTags {
716        names: Vec<String>,
717        reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
718    },
719    /// Remove zero or more tags (M171). Returns names actually removed;
720    /// unknown names are tolerated (matches qBt idempotent `deleteTags`).
721    DeleteTags {
722        names: Vec<String>,
723        reply: oneshot::Sender<Vec<String>>,
724    },
725    /// Snapshot the current tag list (M171). Sorted.
726    ListTags {
727        reply: oneshot::Sender<Vec<String>>,
728    },
729    /// Add the given tags to each torrent in `info_hashes` (M171).
730    /// Unknown info hashes are silently skipped. The engine-layer
731    /// command is a wholesale replacement, so each torrent's tag set
732    /// is read, unioned with the requested additions, and replayed via
733    /// `TorrentHandle::set_tags`.
734    AddTagsToTorrents {
735        info_hashes: Vec<Id20>,
736        tags: Vec<String>,
737        reply: oneshot::Sender<crate::Result<()>>,
738    },
739    /// Remove the given tags from each torrent in `info_hashes` (M171).
740    /// Unknown info hashes are silently skipped.
741    RemoveTagsFromTorrents {
742        info_hashes: Vec<Id20>,
743        tags: Vec<String>,
744        reply: oneshot::Sender<crate::Result<()>>,
745    },
746    /// Remove a torrent and delete its on-disk files (qBt
747    /// `deleteFiles=true`).
748    RemoveTorrentWithFiles {
749        info_hash: Id20,
750        reply: oneshot::Sender<crate::Result<()>>,
751    },
752    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged)
753    /// for a specific torrent.
754    GetWebSeeds {
755        info_hash: Id20,
756        reply: oneshot::Sender<crate::Result<Vec<String>>>,
757    },
758    /// M171 Lane B: snapshot the per-piece state array as qBt codes
759    /// (`0`/`1`/`2`) for a specific torrent.
760    GetPieceStates {
761        info_hash: Id20,
762        reply: oneshot::Sender<crate::Result<Vec<u8>>>,
763    },
764    /// M171 Lane B: paginated piece hash list for a specific torrent.
765    GetPieceHashes {
766        info_hash: Id20,
767        offset: u32,
768        limit: u32,
769        reply: oneshot::Sender<crate::Result<Vec<String>>>,
770    },
771    /// M171 D4: sum of routing-table sizes across the IPv4 and IPv6 DHT
772    /// instances. Returns 0 when neither DHT is enabled.
773    DhtNodeCount {
774        reply: oneshot::Sender<usize>,
775    },
776    /// **TEST-ONLY (v0.173.2).** Inject info-dict bytes into a torrent's
777    /// actor synchronously, returning only after the actor has processed
778    /// it. Used by integration tests in `irontide-api` (A9) that exercise
779    /// post-metadata HTTP surface without spinning up real peers.
780    /// M187: collect per-torrent and per-peer debug state for diagnostics.
781    DebugState {
782        reply: oneshot::Sender<crate::types::DebugState>,
783    },
784    #[cfg(feature = "test-util")]
785    TestInjectMetadata {
786        info_hash: Id20,
787        info_bytes: Vec<u8>,
788        reply: oneshot::Sender<crate::Result<()>>,
789    },
790    Shutdown,
791}
792
793impl SessionCommand {
794    /// Static variant name for the `cmd` field of the M221.1a
795    /// `session_cmd` tracing event. Stable across renames is *not* a
796    /// goal — this is bench-instrumentation telemetry, so the variant
797    /// identifier is the right label.
798    fn name(&self) -> &'static str {
799        match self {
800            Self::AddTorrent { .. } => "AddTorrent",
801            Self::CommitAddTorrent { .. } => "CommitAddTorrent",
802            Self::AddMagnet { .. } => "AddMagnet",
803            Self::RemoveTorrent { .. } => "RemoveTorrent",
804            Self::PauseTorrent { .. } => "PauseTorrent",
805            Self::ResumeTorrent { .. } => "ResumeTorrent",
806            Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
807            Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
808            Self::TorrentStats { .. } => "TorrentStats",
809            Self::TorrentInfo { .. } => "TorrentInfo",
810            Self::ListTorrents { .. } => "ListTorrents",
811            Self::SessionStats { .. } => "SessionStats",
812            Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
813            Self::SaveSessionState { .. } => "SaveSessionState",
814            Self::LoadResumeState { .. } => "LoadResumeState",
815            Self::QueuePosition { .. } => "QueuePosition",
816            Self::SetQueuePosition { .. } => "SetQueuePosition",
817            Self::QueuePositionUp { .. } => "QueuePositionUp",
818            Self::QueuePositionDown { .. } => "QueuePositionDown",
819            Self::QueuePositionTop { .. } => "QueuePositionTop",
820            Self::QueuePositionBottom { .. } => "QueuePositionBottom",
821            Self::BanPeer { .. } => "BanPeer",
822            Self::UnbanPeer { .. } => "UnbanPeer",
823            Self::BannedPeers { .. } => "BannedPeers",
824            Self::SetIpFilter { .. } => "SetIpFilter",
825            Self::GetIpFilter { .. } => "GetIpFilter",
826            Self::GetSettings { .. } => "GetSettings",
827            Self::ApplySettings { .. } => "ApplySettings",
828            Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
829            Self::AddPeers { .. } => "AddPeers",
830            Self::OpenFile { .. } => "OpenFile",
831            Self::ForceReannounce { .. } => "ForceReannounce",
832            Self::TrackerList { .. } => "TrackerList",
833            Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
834            Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
835            Self::GetWebSeedStats { .. } => "GetWebSeedStats",
836            Self::Scrape { .. } => "Scrape",
837            Self::SetFilePriority { .. } => "SetFilePriority",
838            Self::FilePriorities { .. } => "FilePriorities",
839            Self::SetDownloadLimit { .. } => "SetDownloadLimit",
840            Self::SetUploadLimit { .. } => "SetUploadLimit",
841            Self::DownloadLimit { .. } => "DownloadLimit",
842            Self::UploadLimit { .. } => "UploadLimit",
843            Self::SetSequentialDownload { .. } => "SetSequentialDownload",
844            Self::IsSequentialDownload { .. } => "IsSequentialDownload",
845            Self::SetSuperSeeding { .. } => "SetSuperSeeding",
846            Self::IsSuperSeeding { .. } => "IsSuperSeeding",
847            Self::SetSeedMode { .. } => "SetSeedMode",
848            Self::AddTracker { .. } => "AddTracker",
849            Self::ReplaceTrackers { .. } => "ReplaceTrackers",
850            Self::ForceRecheck { .. } => "ForceRecheck",
851            Self::RenameFile { .. } => "RenameFile",
852            Self::SetMaxConnections { .. } => "SetMaxConnections",
853            Self::MaxConnections { .. } => "MaxConnections",
854            Self::SetMaxUploads { .. } => "SetMaxUploads",
855            Self::MaxUploads { .. } => "MaxUploads",
856            Self::GetPeerInfo { .. } => "GetPeerInfo",
857            Self::GetDownloadQueue { .. } => "GetDownloadQueue",
858            Self::HavePiece { .. } => "HavePiece",
859            Self::PieceAvailability { .. } => "PieceAvailability",
860            Self::FileProgress { .. } => "FileProgress",
861            Self::InfoHashesQuery { .. } => "InfoHashesQuery",
862            Self::TorrentFile { .. } => "TorrentFile",
863            Self::TorrentFileV2 { .. } => "TorrentFileV2",
864            Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
865            Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
866            Self::ReadPiece { .. } => "ReadPiece",
867            Self::FlushCache { .. } => "FlushCache",
868            Self::IsValid { .. } => "IsValid",
869            Self::ClearError { .. } => "ClearError",
870            Self::FileStatus { .. } => "FileStatus",
871            Self::Flags { .. } => "Flags",
872            Self::SetFlags { .. } => "SetFlags",
873            Self::UnsetFlags { .. } => "UnsetFlags",
874            Self::ConnectPeer { .. } => "ConnectPeer",
875            Self::DhtPutImmutable { .. } => "DhtPutImmutable",
876            Self::DhtGetImmutable { .. } => "DhtGetImmutable",
877            Self::DhtPutMutable { .. } => "DhtPutMutable",
878            Self::DhtGetMutable { .. } => "DhtGetMutable",
879            Self::SaveResumeState { .. } => "SaveResumeState",
880            Self::PostSessionStats => "PostSessionStats",
881            Self::AddTorrentM170 { .. } => "AddTorrentM170",
882            Self::CreateCategory { .. } => "CreateCategory",
883            Self::EditCategory { .. } => "EditCategory",
884            Self::RemoveCategories { .. } => "RemoveCategories",
885            Self::ListCategories { .. } => "ListCategories",
886            Self::CreateTags { .. } => "CreateTags",
887            Self::DeleteTags { .. } => "DeleteTags",
888            Self::ListTags { .. } => "ListTags",
889            Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
890            Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
891            Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
892            Self::GetWebSeeds { .. } => "GetWebSeeds",
893            Self::GetPieceStates { .. } => "GetPieceStates",
894            Self::GetPieceHashes { .. } => "GetPieceHashes",
895            Self::DhtNodeCount { .. } => "DhtNodeCount",
896            Self::DebugState { .. } => "DebugState",
897            #[cfg(feature = "test-util")]
898            Self::TestInjectMetadata { .. } => "TestInjectMetadata",
899            Self::Shutdown => "Shutdown",
900        }
901    }
902}
903
904/// Channel sender that timestamps each outgoing `SessionCommand` with
905/// the instant it was enqueued, so the actor's receive arm can split
906/// `queue_wait_ms` (sender → receiver) from `handler_ms` (dispatch).
907/// M221.1a — feeds the parallel-7 bench harness.
908#[derive(Clone)]
909struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
910
911impl SessionCmdSender {
912    async fn send(
913        &self,
914        cmd: SessionCommand,
915    ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
916        let sent_at = tokio::time::Instant::now();
917        self.0
918            .send((sent_at, cmd))
919            .await
920            .map_err(|e| mpsc::error::SendError(e.0.1))
921    }
922}
923
924/// Classification of settings-patch fields into "took effect immediately"
925/// versus "requires session restart to apply".
926///
927/// Returned by [`SessionHandle::apply_settings_classified`]. The
928/// `restart_required` list is surfaced as the `X-IronTide-Restart-Pending`
929/// response header by the qBt v2 `setPreferences` handler so clients can
930/// render a "restart to apply" UX affordance (M171 D3.5).
931#[derive(Debug, Clone, Default)]
932pub struct AppliedSettings {
933    /// Settings fields that changed and took effect immediately.
934    pub immediate: Vec<&'static str>,
935    /// Settings fields that changed but require a session restart to
936    /// activate (sub-actor reconfig, listen-socket rebind, DHT/LSD/PEX
937    /// startup, encryption-handshake policy, anonymous-mode peer ID, etc.).
938    pub restart_required: Vec<&'static str>,
939}
940
941/// Classify fields whose runtime application is immediate — rate limiters
942/// (M166), alert mask, peer cap, and enum/flag state that peer/torrent
943/// actors re-read on every tick.
944///
945/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, and `lsd`
946/// move from `restart_required` to `immediate`. The transactional
947/// apply pipeline (B1) now performs the live reconfig:
948/// - `listen_port`: TCP listener rebind + uTP `shutdown_and_wait` + new
949///   bind + NAT `refresh_for_port` (B2/B4/B8 primitives).
950/// - dht: `shutdown_and_wait` (B7 persists routing table) + new
951///   `DhtHandle` + `DhtBroadcast::replace` fans out to torrents (B5+B6).
952/// - lsd: `shutdown_and_wait` (B9 multicast fd guard) + new actor.
953///
954/// `[REGRESSION CRITICAL]`: the wire-format pinned test in
955/// `crates/irontide-api/tests/qbt_v2_set_preferences.rs` asserts the
956/// EXACT field-name set here. Downstream *arr clients parse the
957/// `X-IronTide-Restart-Pending` header — a silent rename = downstream
958/// regression. Add new fields with care; never reuse a field name.
959fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
960    let mut v = Vec::new();
961    if old.download_rate_limit != new.download_rate_limit {
962        v.push("dl_limit");
963    }
964    if old.upload_rate_limit != new.upload_rate_limit {
965        v.push("up_limit");
966    }
967    if old.max_peers_per_torrent != new.max_peers_per_torrent {
968        v.push("max_connec");
969    }
970    if old.max_ratio_action != new.max_ratio_action {
971        v.push("max_ratio_act");
972    }
973    if old.create_subfolder != new.create_subfolder {
974        v.push("create_subfolder_enabled");
975    }
976    if old.auto_manage_torrents != new.auto_manage_torrents {
977        v.push("auto_tmm_enabled");
978    }
979    if old.queueing_enabled != new.queueing_enabled {
980        v.push("queueing_enabled");
981    }
982    if old.seed_ratio_limit != new.seed_ratio_limit {
983        v.push("max_ratio");
984    }
985    // M173 Lane B (B10): graduate listen_port, dht, lsd from
986    // restart_required → immediate. The transactional apply pipeline
987    // (B1) actually performs the live reconfig.
988    if old.listen_port != new.listen_port {
989        v.push("listen_port");
990    }
991    if old.enable_dht != new.enable_dht {
992        v.push("dht");
993    }
994    if old.enable_lsd != new.enable_lsd {
995        v.push("lsd");
996    }
997    // M172a Lane B: CSRF + reverse-proxy settings take effect immediately
998    // because the middleware re-reads Settings per-request (no actor state).
999    // Each qBt wire field name mirrors qBt's own preferences keys so the
1000    // X-IronTide-Restart-Pending header is human-readable on the client.
1001    if old.qbt_compat.csrf_protection_enabled != new.qbt_compat.csrf_protection_enabled {
1002        v.push("web_ui_csrf_protection_enabled");
1003    }
1004    if old.qbt_compat.host_header_validation_enabled
1005        != new.qbt_compat.host_header_validation_enabled
1006    {
1007        v.push("web_ui_host_header_validation_enabled");
1008    }
1009    if old.qbt_compat.web_ui_reverse_proxy_enabled != new.qbt_compat.web_ui_reverse_proxy_enabled {
1010        v.push("web_ui_reverse_proxy_enabled");
1011    }
1012    if old.qbt_compat.web_ui_reverse_proxies_list != new.qbt_compat.web_ui_reverse_proxies_list {
1013        v.push("web_ui_reverse_proxies_list");
1014    }
1015    // M172a Lane C: brute-force ban settings all land as immediate —
1016    // the auth handler reads them from the live `Settings` snapshot per
1017    // request, so the next login reflects the new thresholds without
1018    // a session restart. M225 closes the M173+ shrink path:
1019    // `brute_force_registry_capacity` decreases trigger
1020    // `BruteForceRegistry::shrink_preserving_recent_bans` from the
1021    // qBt v2 `setPreferences` handler (two-tier preservation — every
1022    // active ban survives regardless of new cap; partials fill the
1023    // remainder by `last_touch` descending).
1024    if old.qbt_compat.max_failed_auth_count != new.qbt_compat.max_failed_auth_count {
1025        v.push("web_ui_max_auth_fail_count");
1026    }
1027    if old.qbt_compat.ban_duration_secs != new.qbt_compat.ban_duration_secs {
1028        v.push("web_ui_ban_duration");
1029    }
1030    if old.qbt_compat.bypass_local_auth != new.qbt_compat.bypass_local_auth {
1031        v.push("bypass_local_auth");
1032    }
1033    if old.qbt_compat.bypass_auth_subnet_whitelist != new.qbt_compat.bypass_auth_subnet_whitelist {
1034        v.push("bypass_auth_subnet_whitelist");
1035    }
1036    if old.qbt_compat.brute_force_registry_capacity != new.qbt_compat.brute_force_registry_capacity
1037    {
1038        v.push("brute_force_registry_capacity");
1039    }
1040    // M214: global connection cap. Accept-loop reads from the live `Settings`
1041    // snapshot on each tick — no caller cache to invalidate. The
1042    // `max_peers_per_torrent` field is already classified above as "max_connec"
1043    // (qBt's legacy alias). We introduce "max_connec_global" to disambiguate
1044    // the workspace-wide cap from the per-torrent one.
1045    if old.max_connections_global != new.max_connections_global {
1046        v.push("max_connec_global");
1047    }
1048    // M224: per-torrent upload slot cap. Propagated to in-flight torrents via
1049    // `SettingsDelta::max_uploads_per_torrent` and `apply_delta`; the choker's
1050    // unchoke selection at next tick respects the new cap. Wire alias is
1051    // qBt's `max_uploads_per_torrent` for the X-IronTide-Restart-Pending
1052    // header.
1053    if old.max_uploads_per_torrent != new.max_uploads_per_torrent {
1054        v.push("max_uploads_per_torrent");
1055    }
1056    // M215: seed-time limits. Wire format is minutes; storage is seconds.
1057    // Propagated to in-flight torrents via `SettingsDelta::seed_time_limit_secs`
1058    // and `apply_delta` at `torrent_state.rs:1633-1638` — live, no restart.
1059    // Field name mirrors qBt's wire convention (`max_seeding_time` /
1060    // `max_inactive_seeding_time`) for client-readable
1061    // `X-IronTide-Restart-Pending`.
1062    if old.seed_time_limit_secs != new.seed_time_limit_secs {
1063        v.push("max_seeding_time");
1064    }
1065    if old.inactive_seed_time_limit_secs != new.inactive_seed_time_limit_secs {
1066        v.push("max_inactive_seeding_time");
1067    }
1068    if old.save_resume_interval_secs != new.save_resume_interval_secs {
1069        v.push("save_resume_interval");
1070    }
1071    if old.hashing_threads != new.hashing_threads {
1072        v.push("hashing_threads");
1073    }
1074    if old.ip_filter_enabled != new.ip_filter_enabled {
1075        v.push("ip_filter_enabled");
1076    }
1077    // M226: Notifications / paths / watched folder / network — all immediate.
1078    // Most fields are stored-only (subsystem wiring deferred per plan
1079    // §NOT-in-scope), but they MUST land as immediate so the GUI's
1080    // not-yet-active toggles in M227 don't surface a restart-required
1081    // header on the wire.
1082    if old.notify_on_complete != new.notify_on_complete {
1083        v.push("notify_on_complete");
1084    }
1085    if old.notify_on_error != new.notify_on_error {
1086        v.push("notify_on_error");
1087    }
1088    if old.on_complete_program != new.on_complete_program {
1089        v.push("on_complete_program");
1090    }
1091    if old.use_incomplete_dir != new.use_incomplete_dir {
1092        v.push("use_incomplete_dir");
1093    }
1094    if old.incomplete_dir != new.incomplete_dir {
1095        v.push("incomplete_dir");
1096    }
1097    if old.default_skip_hash_check != new.default_skip_hash_check {
1098        v.push("default_skip_hash_check");
1099    }
1100    if old.incomplete_extension_enabled != new.incomplete_extension_enabled {
1101        v.push("incomplete_extension_enabled");
1102    }
1103    if old.watched_folder != new.watched_folder {
1104        v.push("watched_folder");
1105    }
1106    if old.delete_torrent_after_add != new.delete_torrent_after_add {
1107        v.push("delete_torrent_after_add");
1108    }
1109    if old.move_completed_enabled != new.move_completed_enabled {
1110        v.push("move_completed_enabled");
1111    }
1112    if old.move_completed_to != new.move_completed_to {
1113        v.push("move_completed_to");
1114    }
1115    if old.ip_filter_auto_refresh != new.ip_filter_auto_refresh {
1116        v.push("ip_filter_auto_refresh");
1117    }
1118    if old.web_ui_https_enabled != new.web_ui_https_enabled {
1119        v.push("web_ui_https_enabled");
1120    }
1121    if old.network_interface != new.network_interface {
1122        v.push("network_interface");
1123    }
1124    if old.default_add_paused != new.default_add_paused {
1125        v.push("default_add_paused");
1126    }
1127    v
1128}
1129
1130/// Classify fields whose runtime application STILL requires a session
1131/// restart, post-M173 Lane B graduation:
1132/// - PEX (peer announcement on next handshake — propagation cost)
1133/// - encryption handshake policy (MSE bytes-on-wire)
1134/// - anonymous-mode peer ID
1135/// - download-dir root (in-flight torrents retain their original
1136///   `save_path`; only `next-add` changes — this is intentional, not
1137///   a bug, see HA spec non-goals)
1138///
1139/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, `lsd` were
1140/// removed from this list; they now appear in [`classify_immediate`].
1141/// The transactional apply pipeline (B1) performs their live reconfig.
1142fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1143    let mut v = Vec::new();
1144    if old.enable_pex != new.enable_pex {
1145        v.push("pex");
1146    }
1147    if old.encryption_mode != new.encryption_mode {
1148        v.push("encryption");
1149    }
1150    if old.anonymous_mode != new.anonymous_mode {
1151        v.push("anonymous_mode");
1152    }
1153    if old.download_dir != new.download_dir {
1154        v.push("save_path");
1155    }
1156    // v0.187.3 / 2A: Web UI listen socket — restart-required because the
1157    // axum listener binds at process startup, not on reconfig (the M173
1158    // transactional apply scaffold only covers the BitTorrent listen port).
1159    if old.qbt_compat.port != new.qbt_compat.port {
1160        v.push("webui_port");
1161    }
1162    if old.qbt_compat.bind_address != new.qbt_compat.bind_address {
1163        v.push("webui_bind");
1164    }
1165    // M214: NAT traversal toggles. `irontide_nat::NatHandle::start` consumes a
1166    // `NatConfig` once; the actor has no `update_config` primitive. Hot-toggle
1167    // would require shutdown+restart of the NAT actor (M173 Lane B precedent
1168    // for listen_port). M214 ships the honest round-trip; live reconfig defers.
1169    if old.enable_upnp != new.enable_upnp {
1170        v.push("upnp");
1171    }
1172    if old.enable_natpmp != new.enable_natpmp {
1173        v.push("natpmp");
1174    }
1175    // M214: proxy settings. Outbound peer/tracker connections cache proxy
1176    // configuration at handshake time. Hot-changing mid-session would mix
1177    // pre-/post-change traffic on torn-down sockets vs new sockets. qBt's
1178    // reference behaviour is restart-required for the same reason.
1179    if old.proxy.proxy_type != new.proxy.proxy_type {
1180        v.push("proxy_type");
1181    }
1182    if old.proxy.hostname != new.proxy.hostname {
1183        v.push("proxy_ip");
1184    }
1185    if old.proxy.port != new.proxy.port {
1186        v.push("proxy_port");
1187    }
1188    if old.proxy.username != new.proxy.username {
1189        v.push("proxy_username");
1190    }
1191    if old.proxy.password != new.proxy.password {
1192        v.push("proxy_password");
1193    }
1194    if old.proxy.proxy_peer_connections != new.proxy.proxy_peer_connections {
1195        v.push("proxy_peer_connections");
1196    }
1197    if old.proxy.proxy_hostnames != new.proxy.proxy_hostnames {
1198        v.push("proxy_hostnames");
1199    }
1200    if old.force_proxy != new.force_proxy {
1201        v.push("force_proxy");
1202    }
1203    v
1204}
1205
1206/// Cloneable handle for interacting with a running session.
1207#[derive(Clone)]
1208pub struct SessionHandle {
1209    cmd_tx: SessionCmdSender,
1210    alert_tx: broadcast::Sender<Alert>,
1211    alert_mask: Arc<AtomicU32>,
1212    counters: Arc<crate::stats::SessionCounters>,
1213    /// Network transport factory (M51). Used by future simulation tasks.
1214    #[allow(dead_code)]
1215    factory: Arc<crate::transport::NetworkFactory>,
1216    /// M173 Lane B (B11): in-flight guard for concurrent
1217    /// `apply_settings` / `apply_settings_classified` calls. The
1218    /// guard is acquired BEFORE the command is queued; if a
1219    /// previous reconfig is still in flight, the second caller hits
1220    /// `Error::ConcurrentReconfig` (HTTP 409 Conflict on the qBt
1221    /// v2 `setPreferences` endpoint) instead of racing the first
1222    /// caller's read-modify-write.
1223    reconfig_in_flight: crate::apply::ReconfigInFlight,
1224}
1225
1226impl SessionHandle {
1227    /// Start a new session with the given settings and no plugins.
1228    ///
1229    /// # Errors
1230    ///
1231    /// Returns an error if the connection or binding fails.
1232    pub async fn start(settings: Settings) -> crate::Result<Self> {
1233        Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1234    }
1235
1236    /// Start a new session with a custom disk I/O backend and no plugins.
1237    ///
1238    /// # Errors
1239    ///
1240    /// Returns an error if the connection or binding fails.
1241    pub async fn start_with_backend(
1242        settings: Settings,
1243        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1244    ) -> crate::Result<Self> {
1245        Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1246    }
1247
1248    /// Start a new session with the given settings and extension plugins.
1249    ///
1250    /// # Errors
1251    ///
1252    /// Returns an error if the connection or binding fails.
1253    pub async fn start_with_plugins(
1254        settings: Settings,
1255        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1256    ) -> crate::Result<Self> {
1257        let disk_config = crate::disk::DiskConfig::from(&settings);
1258        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1259        Self::start_with_plugins_and_backend(settings, plugins, backend).await
1260    }
1261
1262    /// Start a new session with the given settings, extension plugins, and
1263    /// a custom disk I/O backend.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns an error if the connection or binding fails.
1268    pub async fn start_with_plugins_and_backend(
1269        settings: Settings,
1270        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1271        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1272    ) -> crate::Result<Self> {
1273        Self::start_full(
1274            settings,
1275            plugins,
1276            backend,
1277            Arc::new(crate::transport::NetworkFactory::tokio()),
1278        )
1279        .await
1280    }
1281
1282    /// Start a new session with the given settings and a custom transport factory.
1283    ///
1284    /// Uses default plugins (none) and default disk backend.
1285    ///
1286    /// # Errors
1287    ///
1288    /// Returns an error if the connection or binding fails.
1289    pub async fn start_with_transport(
1290        settings: Settings,
1291        factory: Arc<crate::transport::NetworkFactory>,
1292    ) -> crate::Result<Self> {
1293        let disk_config = crate::disk::DiskConfig::from(&settings);
1294        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1295        Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1296    }
1297
1298    /// Start a new session with all customizable parameters.
1299    ///
1300    /// This is the most general constructor — all other `start_*` variants
1301    /// delegate to this method. The `factory` parameter controls how TCP
1302    /// listeners and connections are created: use [`crate::transport::NetworkFactory::tokio()`]
1303    /// for real networking or a custom factory for simulation.
1304    ///
1305    /// # Errors
1306    ///
1307    /// Returns an error if the connection or binding fails.
1308    pub async fn start_full(
1309        settings: Settings,
1310        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1311        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1312        factory: Arc<crate::transport::NetworkFactory>,
1313    ) -> crate::Result<Self> {
1314        let mut settings = settings;
1315
1316        // Force proxy mode: all connections must go through proxy.
1317        if settings.force_proxy {
1318            if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1319                return Err(crate::Error::Config(
1320                    "force_proxy requires a proxy to be configured".into(),
1321                ));
1322            }
1323            settings.enable_upnp = false;
1324            settings.enable_natpmp = false;
1325            settings.enable_dht = false;
1326            settings.enable_lsd = false;
1327        }
1328
1329        // Anonymous mode: suppress identity and disable discovery.
1330        if settings.anonymous_mode {
1331            settings.enable_dht = false;
1332            settings.enable_lsd = false;
1333            settings.enable_upnp = false;
1334            settings.enable_natpmp = false;
1335        }
1336
1337        // M172a A3/C2: legacy-plaintext → argon2id migration on the in-memory
1338        // Settings. The config file rewrite is a separate concern handled by
1339        // the CLI layer (see irontide_config::migrate_qbt_credentials_in_file)
1340        // — we only mutate the runtime copy here so login always uses PHC
1341        // hashes regardless of on-disk state.
1342        //
1343        // Failure semantics (C2): on hash error we keep the plaintext in
1344        // memory so verify still works, and emit a WARN. The daemon continues
1345        // — a startup crash would brick an operator on a transient OS error.
1346        match crate::settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1347            Ok(crate::settings::QbtCredentialMigration::Upgraded) => {
1348                warn!(
1349                    "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1350                     persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1351                     next config-touching CLI command to remove the plaintext from disk"
1352                );
1353            }
1354            Ok(crate::settings::QbtCredentialMigration::NoOp) => {}
1355            Err(e) => {
1356                warn!(
1357                    error = %e,
1358                    "qbt_compat: in-memory password migration failed — continuing with \
1359                     legacy plaintext; retry on next daemon start"
1360                );
1361            }
1362        }
1363
1364        let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1365        let cmd_tx = SessionCmdSender(raw_cmd_tx);
1366
1367        // Alert broadcast channel
1368        let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1369        let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1370
1371        // M226 Step 5: spawn the engine-side OS notification dispatcher.
1372        // The dispatcher subscribes to the alert broadcast BEFORE any
1373        // TorrentAdded can fire (H5 — eliminates the missed-cache race
1374        // on first add), reads `notify_on_complete` / `notify_on_error`
1375        // live from a watch channel mirrored by `handle_apply_settings`,
1376        // and exits when either the `notification_shutdown_tx` field on
1377        // `SessionActor` drops OR the alert broadcast closes. Production
1378        // uses `LibNotifySink` (wraps `notify-rust` via spawn_blocking);
1379        // first D-Bus failure logs a single WARN then degrades silently.
1380        let (notification_settings_tx, notification_settings_rx) =
1381            tokio::sync::watch::channel(settings.clone());
1382        let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1383        let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1384            crate::notification::DispatcherOptions {
1385                sink: Box::new(crate::notification::LibNotifySink::new()),
1386                settings_rx: notification_settings_rx,
1387                alerts_rx: alert_tx.subscribe(),
1388                shutdown_rx: notification_shutdown_rx,
1389            },
1390        );
1391
1392        // M226 Step 6: prepare the watched-folder dispatcher's channels.
1393        // The dispatcher itself is spawned at the very end of `start_full`
1394        // (it needs a fully-built `SessionHandle` to clone), but the
1395        // shutdown signal + change-notify must exist before SessionActor
1396        // construction so they can be stored on the actor.
1397        let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1398        let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) =
1399            oneshot::channel::<()>();
1400        // Fresh settings subscription dedicated to the watcher (a watch
1401        // Receiver is single-consumer; the notification dispatcher
1402        // already consumed the original).
1403        let watched_folder_settings_rx = notification_settings_tx.subscribe();
1404
1405        let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1406            match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1407                Ok((handle, rx)) => (Some(handle), Some(rx)),
1408                Err(e) => {
1409                    warn!("LSD unavailable (port 6771): {e}");
1410                    (None, None)
1411                }
1412            }
1413        } else {
1414            (None, None)
1415        };
1416
1417        let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1418            crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1419        ));
1420        let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1421            crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1422        ));
1423
1424        // M225 G4: build the shared admit-gate state BEFORE binding the uTP
1425        // socket so the SocketActor's inbound-SYN path can read from the
1426        // same `Arc<AtomicI32>` / `Arc<AtomicUsize>` / `SharedIpFilter` the
1427        // TCP listener and `handle_apply_settings` use. These three
1428        // allocations are otherwise constructed further down (lines below);
1429        // hoisting them avoids a separate setter on `UtpSocket` and the
1430        // race window it would open.
1431        let ip_filter: SharedIpFilter =
1432            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1433        let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1434            settings.max_connections_global,
1435        ));
1436        let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1437
1438        let utp_admit = {
1439            let ip_filter_for_utp = Arc::clone(&ip_filter);
1440            irontide_utp::AdmitGate::new(
1441                Arc::clone(&max_connections_global),
1442                Arc::clone(&live_connections),
1443                Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1444            )
1445        };
1446
1447        // uTP socket (shared across all torrents).
1448        //
1449        // Stage U: when the factory exposes a `bind_udp` closure (sim
1450        // path), bind the production `UtpSocket` on top of the factory's
1451        // `UdpTransport` so BEP 29 runs unmodified through the in-memory
1452        // packet bus. The tokio factory leaves `bind_udp` unset and the
1453        // direct `UtpSocket::bind` path runs (it owns the FD-level DSCP /
1454        // TCLASS setsockopt, which the sim has no equivalent for).
1455        let (utp_socket, utp_listener) = if settings.enable_utp {
1456            let utp_config = settings.to_utp_config(settings.listen_port);
1457            let bind_addr = utp_config.bind_addr;
1458            let result = if factory.has_bind_udp() {
1459                match factory.bind_udp(bind_addr).await {
1460                    Ok(transport) => {
1461                        irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1462                            transport,
1463                            utp_config,
1464                            utp_admit.clone(),
1465                        )
1466                    }
1467                    Err(e) => Err(irontide_utp::Error::Io(e)),
1468                }
1469            } else {
1470                irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1471            };
1472            match result {
1473                Ok((socket, listener)) => (Some(socket), Some(listener)),
1474                Err(e) => {
1475                    warn!("uTP bind failed: {e}");
1476                    (None, None)
1477                }
1478            }
1479        } else {
1480            (None, None)
1481        };
1482
1483        // IPv6 uTP socket (dual-stack). Sim path skips IPv6 — the sim
1484        // network only models v4 today.
1485        let (utp_socket_v6, utp_listener_v6) =
1486            if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1487                match irontide_utp::UtpSocket::bind_with_admit_gate(
1488                    settings.to_utp_config_v6(settings.listen_port),
1489                    utp_admit.clone(),
1490                )
1491                .await
1492                {
1493                    Ok((socket, listener)) => (Some(socket), Some(listener)),
1494                    Err(e) => {
1495                        debug!("uTP IPv6 bind failed (non-fatal): {e}");
1496                        (None, None)
1497                    }
1498                }
1499            } else {
1500                (None, None)
1501            };
1502
1503        // NAT port mapping (PCP / NAT-PMP / UPnP)
1504        let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1505            let nat_config = settings.to_nat_config();
1506            let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1507            let udp_port = if settings.enable_utp {
1508                Some(settings.listen_port)
1509            } else {
1510                None
1511            };
1512            handle.map_ports(settings.listen_port, udp_port).await;
1513            (Some(handle), Some(events_rx))
1514        } else {
1515            (None, None)
1516        };
1517
1518        // I2P SAM session
1519        let sam_session = if settings.enable_i2p {
1520            let tunnel_config = settings.to_sam_tunnel_config();
1521            match crate::i2p::SamSession::create(
1522                &settings.i2p_hostname,
1523                settings.i2p_port,
1524                "torrent",
1525                tunnel_config,
1526            )
1527            .await
1528            {
1529                Ok(session) => {
1530                    let b32 = session.destination().to_b32_address();
1531                    info!("I2P SAM session created: {}", b32);
1532                    post_alert(
1533                        &alert_tx,
1534                        &alert_mask,
1535                        AlertKind::I2pSessionCreated { b32_address: b32 },
1536                    );
1537                    Some(Arc::new(session))
1538                }
1539                Err(e) => {
1540                    warn!("I2P SAM session failed: {e}");
1541                    post_alert(
1542                        &alert_tx,
1543                        &alert_mask,
1544                        AlertKind::I2pError {
1545                            message: format!("SAM session creation failed: {e}"),
1546                        },
1547                    );
1548                    None
1549                }
1550            }
1551        } else {
1552            None
1553        };
1554
1555        // SSL manager (M42): create if ssl_listen_port != 0 or cert paths are provided
1556        let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1557            match crate::ssl_manager::SslManager::new(&settings) {
1558                Ok(mgr) => {
1559                    info!("SSL manager initialized");
1560                    Some(Arc::new(mgr))
1561                }
1562                Err(e) => {
1563                    warn!(error = %e, "SSL manager initialization failed");
1564                    None
1565                }
1566            }
1567        } else {
1568            None
1569        };
1570
1571        // TCP listener: bind on the main listen port for incoming peer connections.
1572        let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1573            .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1574            .await
1575        {
1576            Ok(l) => {
1577                info!(port = settings.listen_port, "TCP listener started");
1578                Some(l)
1579            }
1580            Err(e) => {
1581                warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1582                None
1583            }
1584        };
1585
1586        // SSL listener (M42): bind if ssl_listen_port != 0
1587        let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1588            .ssl_listen_port
1589            != 0
1590        {
1591            match factory
1592                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1593                .await
1594            {
1595                Ok(l) => {
1596                    info!(port = settings.ssl_listen_port, "SSL listener started");
1597                    Some(l)
1598                }
1599                Err(e) => {
1600                    warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1601                    None
1602                }
1603            }
1604        } else {
1605            None
1606        };
1607
1608        // Start DHT instances
1609        let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1610            match DhtHandle::start(settings.to_dht_config()).await {
1611                Ok((handle, ip_rx)) => {
1612                    info!("DHT v4 started");
1613                    (Some(handle), Some(ip_rx))
1614                }
1615                Err(e) => {
1616                    warn!("DHT v4 start failed: {e}");
1617                    (None, None)
1618                }
1619            }
1620        } else {
1621            (None, None)
1622        };
1623
1624        let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1625            match DhtHandle::start(settings.to_dht_config_v6()).await {
1626                Ok((handle, ip_rx)) => {
1627                    info!("DHT v6 started");
1628                    (Some(handle), Some(ip_rx))
1629                }
1630                Err(e) => {
1631                    debug!("DHT v6 start failed (non-fatal): {e}");
1632                    (None, None)
1633                }
1634            }
1635        } else {
1636            (None, None)
1637        };
1638
1639        // M173 Lane B (B6): seed the broadcast surfaces with the
1640        // initial DHT handles so consumers see exactly the same
1641        // value via the broadcast as the legacy `dht_v4`/`dht_v6`
1642        // fields. B11's apply_settings DHT-restart phase later
1643        // updates the broadcasts via `replace`.
1644        let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1645        let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1646
1647        let ban_config = crate::ban::BanConfig::from(&settings);
1648        let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1649            crate::ban::BanManager::new(ban_config),
1650        ));
1651
1652        // M225 G4: `ip_filter` was hoisted above the uTP bind so the
1653        // SocketActor's admit-gate closure captures the same Arc.
1654
1655        let disk_config = crate::disk::DiskConfig::from(&settings);
1656        let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1657        let (disk_manager, disk_actor_handle) =
1658            crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1659
1660        let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1661            settings.enable_diagnostic_counters,
1662        ));
1663
1664        // M96: Create shared hash pool for parallel piece verification
1665        let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1666            settings.hashing_threads,
1667            64,
1668        ));
1669
1670        // M114: Spawn isolated listener task for TCP/uTP accepts.
1671        let info_hash_registry = Arc::new(DashMap::new());
1672        let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1673        // M224 D3 / M225 G4: `max_connections_global` and `live_connections`
1674        // were hoisted above the uTP bind so the SocketActor's admit gate
1675        // shares them with this listener. handle_apply_settings updates the
1676        // i32 atomic in-place; both transports read it on the next admit.
1677        let listener_task = crate::listener::ListenerTask::new(
1678            tcp_listener,
1679            utp_listener,
1680            utp_listener_v6,
1681            Arc::clone(&info_hash_registry),
1682            validated_tx,
1683            Arc::clone(&max_connections_global),
1684            Arc::clone(&live_connections),
1685        );
1686        // M173 Lane B (B2): spawn via ListenerHandle::spawn so the
1687        // shutdown channel is plumbed in. We hold the full
1688        // `ListenerHandle` (not just the JoinHandle) so the future
1689        // listen-port rebind path (B4) can call
1690        // `shutdown_with_timeout` for a clean port swap. Session
1691        // teardown still works via Drop on the `ListenerHandle` →
1692        // shutdown sender Drop → receiver fires `RecvError` → loop
1693        // exits.
1694        let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1695
1696        let external_ip = settings.external_ip;
1697
1698        // M170: load the category registry once on startup. Errors are
1699        // soft-recovered inside `CategoryRegistry::load`, so this call
1700        // cannot fail in practice.
1701        let category_registry_path = crate::category_manager::resolve_category_registry_path(
1702            settings.category_registry_path.as_deref(),
1703        );
1704        let category_registry = Arc::new(parking_lot::RwLock::new(
1705            crate::category_manager::CategoryRegistry::load(category_registry_path),
1706        ));
1707        // M171: same pattern as the category registry — soft-recover inside
1708        // `TagRegistry::load`, so this never fails in practice.
1709        let tag_registry_path =
1710            crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1711        let tag_registry = Arc::new(parking_lot::RwLock::new(
1712            crate::tag_manager::TagRegistry::load(tag_registry_path),
1713        ));
1714        let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1715        let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1716
1717        let actor = SessionActor {
1718            settings,
1719            // M223 — clone so the actor can self-emit CommitAddTorrent
1720            // from off-actor prep tasks. The handle still owns the
1721            // original sender; both feed the same recv queue.
1722            commit_tx: cmd_tx.clone(),
1723            torrents: HashMap::new(),
1724            dht_v4,
1725            dht_v6,
1726            dht_v4_broadcast,
1727            dht_v6_broadcast,
1728            lsd,
1729            lsd_peers_rx,
1730            cmd_rx,
1731            alert_tx: alert_tx.clone(),
1732            alert_mask: Arc::clone(&alert_mask),
1733            global_upload_bucket,
1734            global_download_bucket,
1735            utp_socket,
1736            utp_socket_v6,
1737            nat,
1738            nat_events_rx,
1739            ban_manager,
1740            ip_filter,
1741            disk_manager,
1742            disk_actor_handle,
1743            external_ip,
1744            dht_v4_ip_rx,
1745            dht_v6_ip_rx,
1746            plugins,
1747            sam_session,
1748            ssl_manager,
1749            ssl_listener,
1750            validated_conn_rx,
1751            info_hash_registry,
1752            _listener_task: listener_handle,
1753            max_connections_global,
1754            live_connections,
1755            counters: Arc::clone(&counters),
1756            factory: Arc::clone(&factory),
1757            hash_pool,
1758            category_registry,
1759            tag_registry,
1760            deletion_grace,
1761            // Clone so the SessionHandle can hold the same guard.
1762            reconfig_in_flight: reconfig_in_flight.clone(),
1763            self_alert_rx: alert_tx.subscribe(),
1764            resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1765            notification_settings_tx,
1766            notification_shutdown_tx,
1767            watched_folder_changed: Arc::clone(&watched_folder_changed),
1768            watched_folder_shutdown_tx,
1769        };
1770
1771        let join_handle = tokio::spawn(actor.run());
1772        tokio::spawn(async move {
1773            match join_handle.await {
1774                Ok(()) => {
1775                    tracing::warn!("session actor exited cleanly");
1776                }
1777                Err(e) if e.is_panic() => {
1778                    let panic_payload = e.into_panic();
1779                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1780                        (*s).to_string()
1781                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1782                        s.clone()
1783                    } else {
1784                        "unknown panic payload".to_string()
1785                    };
1786                    tracing::error!("session actor PANICKED: {msg}");
1787                }
1788                Err(e) => {
1789                    tracing::error!("session actor task error: {e}");
1790                }
1791            }
1792        });
1793        let handle = Self {
1794            cmd_tx,
1795            alert_tx,
1796            alert_mask,
1797            counters,
1798            factory,
1799            // M173 Lane B (B11): share the in-flight guard with the
1800            // SessionActor — the actor's apply pipeline can also
1801            // check it for symmetric guarantees, but the HANDLE-side
1802            // try_lock is what catches caller-side races (two
1803            // concurrent setPreferences requests).
1804            reconfig_in_flight,
1805        };
1806
1807        // M226 Step 6: spawn the watched-folder dispatcher AFTER the
1808        // SessionHandle is built so we can hand it a `.clone()`. The
1809        // dispatcher calls `handle.add_torrent(params)` for every
1810        // .torrent file dropped in `settings.watched_folder`. Drop of
1811        // the matching `watched_folder_shutdown_tx` on the actor (or
1812        // explicit send) signals the dispatcher to exit.
1813        let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1814            handle.clone(),
1815            watched_folder_settings_rx,
1816            watched_folder_changed,
1817            watched_folder_shutdown_rx,
1818        );
1819
1820        Ok(handle)
1821    }
1822
1823    /// Add a torrent from parsed .torrent metadata (v1, v2, or hybrid).
1824    ///
1825    /// Low-level entry point; used by the `irontide` facade and internal
1826    /// sim/test code. For user-facing adds, prefer
1827    /// [`add_torrent`](Self::add_torrent) with [`AddTorrentParams`],
1828    /// which resolves categories and `download_dir` via M170 semantics.
1829    ///
1830    /// # Errors
1831    ///
1832    /// Returns an error if the torrent cannot be added or the session is shut down.
1833    pub async fn add_torrent_with_meta(
1834        &self,
1835        meta: irontide_core::TorrentMeta,
1836        storage: Option<Arc<dyn TorrentStorage>>,
1837    ) -> crate::Result<Id20> {
1838        self.add_torrent_with_dir(meta, storage, None).await
1839    }
1840
1841    /// Unified add entry (M170).
1842    ///
1843    /// Resolves the download directory using the precedence documented
1844    /// on [`AddTorrentParams`] and the category registry, then delegates
1845    /// to the magnet or bytes-add path as appropriate. Returns the v1
1846    /// info hash of the new torrent.
1847    ///
1848    /// # Errors
1849    ///
1850    /// - [`Error::CategoryNotFound`](crate::Error::CategoryNotFound) when
1851    ///   `params.category` names a category that is not in the registry.
1852    /// - [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved)
1853    ///   when another task is currently deleting files for the same info
1854    ///   hash (mapped to 409 Conflict by the qBt API).
1855    /// - Propagates parsing errors for bad magnet URIs / .torrent bytes.
1856    /// - Propagates the existing `DuplicateTorrent` / `SessionAtCapacity`
1857    ///   error shapes unchanged.
1858    pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1859        let (tx, rx) = oneshot::channel();
1860        self.cmd_tx
1861            .send(SessionCommand::AddTorrentM170 {
1862                params: Box::new(params),
1863                reply: tx,
1864            })
1865            .await
1866            .map_err(|_| crate::Error::Shutdown)?;
1867        rx.await.map_err(|_| crate::Error::Shutdown)?
1868    }
1869
1870    /// Create a new qBt-compat category (M170).
1871    ///
1872    /// # Errors
1873    ///
1874    /// Returns [`CategoryError`](crate::CategoryError) on name validation
1875    /// failure, duplicate name, or persistence I/O error.
1876    pub async fn create_category(
1877        &self,
1878        name: String,
1879        save_path: PathBuf,
1880    ) -> Result<(), crate::category_manager::CategoryError> {
1881        let (tx, rx) = oneshot::channel();
1882        if self
1883            .cmd_tx
1884            .send(SessionCommand::CreateCategory {
1885                name,
1886                save_path,
1887                reply: tx,
1888            })
1889            .await
1890            .is_err()
1891        {
1892            return Err(crate::category_manager::CategoryError::Persistence(
1893                std::io::Error::other("session shutting down"),
1894            ));
1895        }
1896        rx.await.unwrap_or_else(|_| {
1897            Err(crate::category_manager::CategoryError::Persistence(
1898                std::io::Error::other("session shutting down"),
1899            ))
1900        })
1901    }
1902
1903    /// Update the `save_path` on an existing category (M170).
1904    ///
1905    /// # Errors
1906    ///
1907    /// Returns [`CategoryError::NotFound`](crate::CategoryError::NotFound)
1908    /// when the category does not exist, plus the same persistence /
1909    /// validation error shapes as [`create_category`](Self::create_category).
1910    pub async fn edit_category(
1911        &self,
1912        name: String,
1913        save_path: PathBuf,
1914    ) -> Result<(), crate::category_manager::CategoryError> {
1915        let (tx, rx) = oneshot::channel();
1916        if self
1917            .cmd_tx
1918            .send(SessionCommand::EditCategory {
1919                name,
1920                save_path,
1921                reply: tx,
1922            })
1923            .await
1924            .is_err()
1925        {
1926            return Err(crate::category_manager::CategoryError::Persistence(
1927                std::io::Error::other("session shutting down"),
1928            ));
1929        }
1930        rx.await.unwrap_or_else(|_| {
1931            Err(crate::category_manager::CategoryError::Persistence(
1932                std::io::Error::other("session shutting down"),
1933            ))
1934        })
1935    }
1936
1937    /// Remove zero or more categories (M170). Unknown names are tolerated
1938    /// (qBt behaviour). Returns the names that were actually removed.
1939    /// After removal, any torrent assigned to a removed category has its
1940    /// `category` label cleared and its resume data marked dirty.
1941    pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1942        let (tx, rx) = oneshot::channel();
1943        if self
1944            .cmd_tx
1945            .send(SessionCommand::RemoveCategories { names, reply: tx })
1946            .await
1947            .is_err()
1948        {
1949            return Vec::new();
1950        }
1951        rx.await.unwrap_or_default()
1952    }
1953
1954    /// Snapshot the current category list (M170).
1955    pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1956        let (tx, rx) = oneshot::channel();
1957        if self
1958            .cmd_tx
1959            .send(SessionCommand::ListCategories { reply: tx })
1960            .await
1961            .is_err()
1962        {
1963            return Vec::new();
1964        }
1965        rx.await.unwrap_or_default()
1966    }
1967
1968    /// List every tag name currently in the registry (M171). Sorted.
1969    pub async fn list_tags(&self) -> Vec<String> {
1970        let (tx, rx) = oneshot::channel();
1971        if self
1972            .cmd_tx
1973            .send(SessionCommand::ListTags { reply: tx })
1974            .await
1975            .is_err()
1976        {
1977            return Vec::new();
1978        }
1979        rx.await.unwrap_or_default()
1980    }
1981
1982    /// Create a batch of tags (M171). Returns one
1983    /// `Result<(), TagError>` per requested name so the caller can
1984    /// distinguish which names already existed and which were newly
1985    /// created. Persistence is best-effort on success; any partial
1986    /// persistence failure warns but does not change the per-call reply.
1987    pub async fn create_tags(
1988        &self,
1989        names: Vec<String>,
1990    ) -> Vec<Result<(), crate::tag_manager::TagError>> {
1991        let (tx, rx) = oneshot::channel();
1992        if self
1993            .cmd_tx
1994            .send(SessionCommand::CreateTags { names, reply: tx })
1995            .await
1996            .is_err()
1997        {
1998            return Vec::new();
1999        }
2000        rx.await.unwrap_or_default()
2001    }
2002
2003    /// Delete a batch of tags (M171). Returns the subset of names that
2004    /// were actually present at call time (unknown names are silently
2005    /// skipped, matching qBt's idempotent `deleteTags`).
2006    pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
2007        let (tx, rx) = oneshot::channel();
2008        if self
2009            .cmd_tx
2010            .send(SessionCommand::DeleteTags { names, reply: tx })
2011            .await
2012            .is_err()
2013        {
2014            return Vec::new();
2015        }
2016        rx.await.unwrap_or_default()
2017    }
2018
2019    /// Add the given tags to each torrent in `hashes` (M171).
2020    /// Idempotent — tags that a torrent already has are left alone.
2021    /// Unknown info hashes are silently skipped.
2022    ///
2023    /// # Errors
2024    ///
2025    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
2026    /// session's command channel has closed.
2027    pub async fn add_tags_to_torrents(
2028        &self,
2029        hashes: Vec<Id20>,
2030        tags: Vec<String>,
2031    ) -> crate::Result<()> {
2032        let (tx, rx) = oneshot::channel();
2033        self.cmd_tx
2034            .send(SessionCommand::AddTagsToTorrents {
2035                info_hashes: hashes,
2036                tags,
2037                reply: tx,
2038            })
2039            .await
2040            .map_err(|_| crate::Error::Shutdown)?;
2041        rx.await.map_err(|_| crate::Error::Shutdown)?
2042    }
2043
2044    /// Remove the given tags from each torrent in `hashes` (M171).
2045    /// Unknown info hashes are silently skipped.
2046    ///
2047    /// # Errors
2048    ///
2049    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
2050    /// session's command channel has closed.
2051    pub async fn remove_tags_from_torrents(
2052        &self,
2053        hashes: Vec<Id20>,
2054        tags: Vec<String>,
2055    ) -> crate::Result<()> {
2056        let (tx, rx) = oneshot::channel();
2057        self.cmd_tx
2058            .send(SessionCommand::RemoveTagsFromTorrents {
2059                info_hashes: hashes,
2060                tags,
2061                reply: tx,
2062            })
2063            .await
2064            .map_err(|_| crate::Error::Shutdown)?;
2065        rx.await.map_err(|_| crate::Error::Shutdown)?
2066    }
2067
2068    /// Remove a torrent and delete its on-disk files (M170,
2069    /// `deleteFiles=true`).
2070    ///
2071    /// Pauses the torrent, closes storage handles, then dispatches a
2072    /// `spawn_blocking` file-removal walk via
2073    /// [`delete_torrent_files_sync`](irontide_storage::delete_torrent_files_sync).
2074    /// The info hash is placed in a deletion grace set for the duration of
2075    /// the walk; concurrent calls to [`add_torrent`](Self::add_torrent)
2076    /// with the same hash return [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved).
2077    ///
2078    /// # Errors
2079    ///
2080    /// Returns [`Error::TorrentNotFound`](crate::Error::TorrentNotFound)
2081    /// if the info hash is not in the session. I/O failures during file
2082    /// removal are logged and swallowed — always returns `Ok(())` when
2083    /// the torrent is found.
2084    pub async fn remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
2085        let (tx, rx) = oneshot::channel();
2086        self.cmd_tx
2087            .send(SessionCommand::RemoveTorrentWithFiles {
2088                info_hash,
2089                reply: tx,
2090            })
2091            .await
2092            .map_err(|_| crate::Error::Shutdown)?;
2093        rx.await.map_err(|_| crate::Error::Shutdown)?
2094    }
2095
2096    /// Add a torrent with an optional per-torrent download directory override.
2097    ///
2098    /// # Errors
2099    ///
2100    /// Returns an error if the torrent cannot be added or the session is shut down.
2101    pub async fn add_torrent_with_dir(
2102        &self,
2103        meta: irontide_core::TorrentMeta,
2104        storage: Option<Arc<dyn TorrentStorage>>,
2105        download_dir: Option<PathBuf>,
2106    ) -> crate::Result<Id20> {
2107        let (tx, rx) = oneshot::channel();
2108        self.cmd_tx
2109            .send(SessionCommand::AddTorrent {
2110                meta: Box::new(meta),
2111                storage,
2112                download_dir,
2113                reply: tx,
2114            })
2115            .await
2116            .map_err(|_| crate::Error::Shutdown)?;
2117        rx.await.map_err(|_| crate::Error::Shutdown)?
2118    }
2119
2120    /// Add a torrent from a magnet link (metadata fetched via BEP 9).
2121    ///
2122    /// # Errors
2123    ///
2124    /// Returns an error if the torrent cannot be added or the session is shut down.
2125    pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2126        self.add_magnet_with_dir(magnet, None).await
2127    }
2128
2129    /// Add a magnet link with an optional per-torrent download directory override.
2130    ///
2131    /// # Errors
2132    ///
2133    /// Returns an error if the torrent cannot be added or the session is shut down.
2134    pub async fn add_magnet_with_dir(
2135        &self,
2136        magnet: Magnet,
2137        download_dir: Option<PathBuf>,
2138    ) -> crate::Result<Id20> {
2139        let (tx, rx) = oneshot::channel();
2140        self.cmd_tx
2141            .send(SessionCommand::AddMagnet {
2142                magnet,
2143                download_dir,
2144                reply: tx,
2145            })
2146            .await
2147            .map_err(|_| crate::Error::Shutdown)?;
2148        rx.await.map_err(|_| crate::Error::Shutdown)?
2149    }
2150
2151    /// Remove a torrent from the session.
2152    ///
2153    /// # Errors
2154    ///
2155    /// Returns an error if the torrent is not found or the session is shut down.
2156    pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2157        let (tx, rx) = oneshot::channel();
2158        self.cmd_tx
2159            .send(SessionCommand::RemoveTorrent {
2160                info_hash,
2161                reply: tx,
2162            })
2163            .await
2164            .map_err(|_| crate::Error::Shutdown)?;
2165        rx.await.map_err(|_| crate::Error::Shutdown)?
2166    }
2167
2168    /// Pause a torrent.
2169    ///
2170    /// # Errors
2171    ///
2172    /// Returns an error if the session is shut down.
2173    pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2174        let (tx, rx) = oneshot::channel();
2175        self.cmd_tx
2176            .send(SessionCommand::PauseTorrent {
2177                info_hash,
2178                reply: tx,
2179            })
2180            .await
2181            .map_err(|_| crate::Error::Shutdown)?;
2182        rx.await.map_err(|_| crate::Error::Shutdown)?
2183    }
2184
2185    /// Resume a paused torrent.
2186    ///
2187    /// # Errors
2188    ///
2189    /// Returns an error if the session is shut down.
2190    pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2191        let (tx, rx) = oneshot::channel();
2192        self.cmd_tx
2193            .send(SessionCommand::ResumeTorrent {
2194                info_hash,
2195                reply: tx,
2196            })
2197            .await
2198            .map_err(|_| crate::Error::Shutdown)?;
2199        rx.await.map_err(|_| crate::Error::Shutdown)?
2200    }
2201
2202    /// Force-resume a torrent, bypassing queue limits.
2203    ///
2204    /// # Errors
2205    ///
2206    /// Returns an error if the session is shut down.
2207    pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2208        let (tx, rx) = oneshot::channel();
2209        self.cmd_tx
2210            .send(SessionCommand::ForceResumeTorrent {
2211                info_hash,
2212                reply: tx,
2213            })
2214            .await
2215            .map_err(|_| crate::Error::Shutdown)?;
2216        rx.await.map_err(|_| crate::Error::Shutdown)?
2217    }
2218
2219    /// Set a per-torrent seed ratio limit override (`None` = use session default).
2220    ///
2221    /// # Errors
2222    ///
2223    /// Returns an error if the session is shut down.
2224    pub async fn set_torrent_seed_ratio(
2225        &self,
2226        info_hash: Id20,
2227        limit: Option<f64>,
2228    ) -> crate::Result<()> {
2229        let (tx, rx) = oneshot::channel();
2230        self.cmd_tx
2231            .send(SessionCommand::SetTorrentSeedRatio {
2232                info_hash,
2233                limit,
2234                reply: tx,
2235            })
2236            .await
2237            .map_err(|_| crate::Error::Shutdown)?;
2238        rx.await.map_err(|_| crate::Error::Shutdown)?
2239    }
2240
2241    /// Get statistics for a specific torrent.
2242    ///
2243    /// # Errors
2244    ///
2245    /// Returns an error if the session is shut down.
2246    pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2247        let (tx, rx) = oneshot::channel();
2248        self.cmd_tx
2249            .send(SessionCommand::TorrentStats {
2250                info_hash,
2251                reply: tx,
2252            })
2253            .await
2254            .map_err(|_| crate::Error::Shutdown)?;
2255        rx.await.map_err(|_| crate::Error::Shutdown)?
2256    }
2257
2258    /// Snapshot per-peer cumulative unchoke duration for the given torrent.
2259    ///
2260    /// Returns `Ok(Some(map))` when the torrent exists. Each entry is the
2261    /// total time we (this session) had that peer unchoked over the
2262    /// torrent's lifetime, including reconnects (durations are flushed
2263    /// into a per-(SocketAddr × torrent) map on disconnect and re-merged
2264    /// here at query time).
2265    ///
2266    /// Returns `Ok(None)` when the torrent is unknown to this session —
2267    /// the explicit contract distinguishes "torrent missing" from "exists
2268    /// but no peers were ever unchoked".
2269    ///
2270    /// # Errors
2271    ///
2272    /// Returns an error if the session is shut down.
2273    pub async fn peer_unchoke_durations(
2274        &self,
2275        info_hash: Id20,
2276    ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2277        let (tx, rx) = oneshot::channel();
2278        self.cmd_tx
2279            .send(SessionCommand::QueryUnchokeDurations {
2280                info_hash,
2281                reply: tx,
2282            })
2283            .await
2284            .map_err(|_| crate::Error::Shutdown)?;
2285        rx.await.map_err(|_| crate::Error::Shutdown)
2286    }
2287
2288    /// Get metadata info for a specific torrent.
2289    ///
2290    /// # Errors
2291    ///
2292    /// Returns an error if the session is shut down.
2293    pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2294        let (tx, rx) = oneshot::channel();
2295        self.cmd_tx
2296            .send(SessionCommand::TorrentInfo {
2297                info_hash,
2298                reply: tx,
2299            })
2300            .await
2301            .map_err(|_| crate::Error::Shutdown)?;
2302        rx.await.map_err(|_| crate::Error::Shutdown)?
2303    }
2304
2305    /// List all active torrent info hashes.
2306    ///
2307    /// # Errors
2308    ///
2309    /// Returns an error if the session is shut down.
2310    pub async fn list_torrents(&self) -> crate::Result<Vec<Id20>> {
2311        let (tx, rx) = oneshot::channel();
2312        self.cmd_tx
2313            .send(SessionCommand::ListTorrents { reply: tx })
2314            .await
2315            .map_err(|_| crate::Error::Shutdown)?;
2316        rx.await.map_err(|_| crate::Error::Shutdown)
2317    }
2318
2319    /// Get aggregate session statistics.
2320    ///
2321    /// # Errors
2322    ///
2323    /// Returns an error if the session is shut down.
2324    pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2325        let (tx, rx) = oneshot::channel();
2326        self.cmd_tx
2327            .send(SessionCommand::SessionStats { reply: tx })
2328            .await
2329            .map_err(|_| crate::Error::Shutdown)?;
2330        rx.await.map_err(|_| crate::Error::Shutdown)
2331    }
2332
2333    /// Collect per-torrent and per-peer debug state for diagnosing dispatch
2334    /// throughput regressions (M187).
2335    ///
2336    /// Individual torrents that do not respond within 500 ms are skipped so
2337    /// the endpoint always returns partial results rather than failing.
2338    ///
2339    /// # Errors
2340    ///
2341    /// Returns an error if the session is shut down.
2342    pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2343        let (tx, rx) = oneshot::channel();
2344        self.cmd_tx
2345            .send(SessionCommand::DebugState { reply: tx })
2346            .await
2347            .map_err(|_| crate::Error::Shutdown)?;
2348        // Use a generous 5 s overall timeout — the per-torrent timeouts are
2349        // 500 ms inside the actor, but with many torrents the total can add up.
2350        tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2351            .await
2352            .map_err(|_| crate::Error::Shutdown)?
2353            .map_err(|_| crate::Error::Shutdown)
2354    }
2355
2356    /// Subscribe to all alerts passing the session-level mask.
2357    #[must_use]
2358    pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2359        self.alert_tx.subscribe()
2360    }
2361
2362    /// Subscribe with per-subscriber category filtering.
2363    #[must_use]
2364    pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2365        AlertStream::new(self.alert_tx.subscribe(), filter)
2366    }
2367
2368    /// Trigger an immediate session stats snapshot and alert.
2369    ///
2370    /// # Errors
2371    ///
2372    /// Returns an error if the session is shut down.
2373    pub async fn post_session_stats(&self) -> crate::Result<()> {
2374        self.cmd_tx
2375            .send(SessionCommand::PostSessionStats)
2376            .await
2377            .map_err(|_| crate::Error::Shutdown)
2378    }
2379
2380    /// Access the shared atomic counters (read-only handle).
2381    #[must_use]
2382    pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2383        &self.counters
2384    }
2385
2386    /// Atomically update the session-level alert mask.
2387    pub fn set_alert_mask(&self, mask: AlertCategory) {
2388        self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2389    }
2390
2391    /// Read the current session-level alert mask.
2392    #[must_use]
2393    pub fn alert_mask(&self) -> AlertCategory {
2394        AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2395    }
2396
2397    /// Add peers to a specific torrent by info hash.
2398    ///
2399    /// # Errors
2400    ///
2401    /// Returns an error if the session is shut down.
2402    pub async fn add_peers(
2403        &self,
2404        info_hash: Id20,
2405        peers: Vec<SocketAddr>,
2406        source: crate::peer_state::PeerSource,
2407    ) -> crate::Result<()> {
2408        let (tx, rx) = oneshot::channel();
2409        self.cmd_tx
2410            .send(SessionCommand::AddPeers {
2411                info_hash,
2412                peers,
2413                source,
2414                reply: tx,
2415            })
2416            .await
2417            .map_err(|_| crate::Error::Shutdown)?;
2418        rx.await.map_err(|_| crate::Error::Shutdown)?
2419    }
2420
2421    /// Gracefully shut down the session and all torrents.
2422    ///
2423    /// # Errors
2424    ///
2425    /// Returns an error if the session is shut down.
2426    pub async fn shutdown(&self) -> crate::Result<()> {
2427        // Timeout prevents hang if SessionActor is processing a heavy batch
2428        let _ = tokio::time::timeout(
2429            std::time::Duration::from_secs(10),
2430            self.cmd_tx.send(SessionCommand::Shutdown),
2431        )
2432        .await;
2433        Ok(())
2434    }
2435
2436    /// Save resume data for a specific torrent.
2437    ///
2438    /// # Errors
2439    ///
2440    /// Returns an error if the I/O operation fails.
2441    pub async fn save_torrent_resume_data(
2442        &self,
2443        info_hash: Id20,
2444    ) -> crate::Result<irontide_core::FastResumeData> {
2445        let (tx, rx) = oneshot::channel();
2446        self.cmd_tx
2447            .send(SessionCommand::SaveTorrentResumeData {
2448                info_hash,
2449                reply: tx,
2450            })
2451            .await
2452            .map_err(|_| crate::Error::Shutdown)?;
2453        rx.await.map_err(|_| crate::Error::Shutdown)?
2454    }
2455
2456    /// Save full session state (all torrent resume data + DHT node cache).
2457    ///
2458    /// # Errors
2459    ///
2460    /// Returns an error if the I/O operation fails.
2461    pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2462        let (tx, rx) = oneshot::channel();
2463        self.cmd_tx
2464            .send(SessionCommand::SaveSessionState { reply: tx })
2465            .await
2466            .map_err(|_| crate::Error::Shutdown)?;
2467        rx.await.map_err(|_| crate::Error::Shutdown)?
2468    }
2469
2470    /// Load and restore torrents from per-torrent resume files on disk.
2471    ///
2472    /// Scans the resume directory for `.resume` files, deserializes each one,
2473    /// reconstructs the torrent metadata, and re-adds it to the session. For
2474    /// resolved torrents (with stored info dict), the piece bitmap is restored.
2475    /// Unresolved magnets are re-added as magnet links.
2476    ///
2477    /// # Errors
2478    ///
2479    /// Returns [`crate::Error::Shutdown`] if the session has been shut down.
2480    pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2481        let (tx, rx) = oneshot::channel();
2482        self.cmd_tx
2483            .send(SessionCommand::LoadResumeState { reply: tx })
2484            .await
2485            .map_err(|_| crate::Error::Shutdown)?;
2486        rx.await.map_err(|_| crate::Error::Shutdown)?
2487    }
2488
2489    /// Save per-torrent resume files for all dirty torrents.
2490    ///
2491    /// Iterates every torrent in the session, checks the `need_save_resume`
2492    /// dirty flag, serializes resume data to disk, and clears the flag.
2493    /// Returns the number of files written.
2494    ///
2495    /// # Errors
2496    ///
2497    /// Returns [`Error::Shutdown`] if the session actor has stopped.
2498    pub async fn save_resume_state(&self) -> crate::Result<usize> {
2499        let (tx, rx) = oneshot::channel();
2500        self.cmd_tx
2501            .send(SessionCommand::SaveResumeState { reply: tx })
2502            .await
2503            .map_err(|_| crate::Error::Shutdown)?;
2504        rx.await.map_err(|_| crate::Error::Shutdown)?
2505    }
2506
2507    /// Get the queue position of a torrent. Returns -1 if not auto-managed.
2508    ///
2509    /// # Errors
2510    ///
2511    /// Returns an error if the session is shut down.
2512    pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2513        let (tx, rx) = oneshot::channel();
2514        self.cmd_tx
2515            .send(SessionCommand::QueuePosition {
2516                info_hash,
2517                reply: tx,
2518            })
2519            .await
2520            .map_err(|_| crate::Error::Shutdown)?;
2521        rx.await.map_err(|_| crate::Error::Shutdown)?
2522    }
2523
2524    /// Set the absolute queue position of a torrent. Shifts other torrents.
2525    ///
2526    /// # Errors
2527    ///
2528    /// Returns an error if the session is shut down.
2529    pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2530        let (tx, rx) = oneshot::channel();
2531        self.cmd_tx
2532            .send(SessionCommand::SetQueuePosition {
2533                info_hash,
2534                pos,
2535                reply: tx,
2536            })
2537            .await
2538            .map_err(|_| crate::Error::Shutdown)?;
2539        rx.await.map_err(|_| crate::Error::Shutdown)?
2540    }
2541
2542    /// Move a torrent one position up (lower number = higher priority).
2543    ///
2544    /// # Errors
2545    ///
2546    /// Returns an error if the session is shut down.
2547    pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2548        let (tx, rx) = oneshot::channel();
2549        self.cmd_tx
2550            .send(SessionCommand::QueuePositionUp {
2551                info_hash,
2552                reply: tx,
2553            })
2554            .await
2555            .map_err(|_| crate::Error::Shutdown)?;
2556        rx.await.map_err(|_| crate::Error::Shutdown)?
2557    }
2558
2559    /// Move a torrent one position down.
2560    ///
2561    /// # Errors
2562    ///
2563    /// Returns an error if the session is shut down.
2564    pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2565        let (tx, rx) = oneshot::channel();
2566        self.cmd_tx
2567            .send(SessionCommand::QueuePositionDown {
2568                info_hash,
2569                reply: tx,
2570            })
2571            .await
2572            .map_err(|_| crate::Error::Shutdown)?;
2573        rx.await.map_err(|_| crate::Error::Shutdown)?
2574    }
2575
2576    /// Move a torrent to position 0 (highest priority).
2577    ///
2578    /// # Errors
2579    ///
2580    /// Returns an error if the session is shut down.
2581    pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2582        let (tx, rx) = oneshot::channel();
2583        self.cmd_tx
2584            .send(SessionCommand::QueuePositionTop {
2585                info_hash,
2586                reply: tx,
2587            })
2588            .await
2589            .map_err(|_| crate::Error::Shutdown)?;
2590        rx.await.map_err(|_| crate::Error::Shutdown)?
2591    }
2592
2593    /// Move a torrent to the last position (lowest priority).
2594    ///
2595    /// # Errors
2596    ///
2597    /// Returns an error if the session is shut down.
2598    pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2599        let (tx, rx) = oneshot::channel();
2600        self.cmd_tx
2601            .send(SessionCommand::QueuePositionBottom {
2602                info_hash,
2603                reply: tx,
2604            })
2605            .await
2606            .map_err(|_| crate::Error::Shutdown)?;
2607        rx.await.map_err(|_| crate::Error::Shutdown)?
2608    }
2609
2610    /// Ban a peer IP session-wide. All torrents will disconnect and refuse this IP.
2611    ///
2612    /// # Errors
2613    ///
2614    /// Returns an error if the session is shut down.
2615    pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2616        let (tx, rx) = oneshot::channel();
2617        self.cmd_tx
2618            .send(SessionCommand::BanPeer { ip, reply: tx })
2619            .await
2620            .map_err(|_| crate::Error::Shutdown)?;
2621        rx.await.map_err(|_| crate::Error::Shutdown)
2622    }
2623
2624    /// Remove a ban and clear strikes for an IP. Returns `true` if the IP was banned.
2625    ///
2626    /// # Errors
2627    ///
2628    /// Returns an error if the session is shut down.
2629    pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2630        let (tx, rx) = oneshot::channel();
2631        self.cmd_tx
2632            .send(SessionCommand::UnbanPeer { ip, reply: tx })
2633            .await
2634            .map_err(|_| crate::Error::Shutdown)?;
2635        rx.await.map_err(|_| crate::Error::Shutdown)
2636    }
2637
2638    /// Replace the session-wide IP filter. Connected peers that are now blocked will
2639    /// be refused on subsequent connection attempts.
2640    ///
2641    /// # Errors
2642    ///
2643    /// Returns an error if the session is shut down.
2644    pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2645        let (tx, rx) = oneshot::channel();
2646        self.cmd_tx
2647            .send(SessionCommand::SetIpFilter { filter, reply: tx })
2648            .await
2649            .map_err(|_| crate::Error::Shutdown)?;
2650        rx.await.map_err(|_| crate::Error::Shutdown)
2651    }
2652
2653    /// Get a clone of the current IP filter.
2654    ///
2655    /// # Errors
2656    ///
2657    /// Returns an error if the session is shut down.
2658    pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2659        let (tx, rx) = oneshot::channel();
2660        self.cmd_tx
2661            .send(SessionCommand::GetIpFilter { reply: tx })
2662            .await
2663            .map_err(|_| crate::Error::Shutdown)?;
2664        rx.await.map_err(|_| crate::Error::Shutdown)
2665    }
2666
2667    /// Get a clone of the current session settings.
2668    ///
2669    /// # Errors
2670    ///
2671    /// Returns an error if the session is shut down.
2672    pub async fn settings(&self) -> crate::Result<Settings> {
2673        let (tx, rx) = oneshot::channel();
2674        self.cmd_tx
2675            .send(SessionCommand::GetSettings { reply: tx })
2676            .await
2677            .map_err(|_| crate::Error::Shutdown)?;
2678        rx.await.map_err(|_| crate::Error::Shutdown)
2679    }
2680
2681    /// Apply new settings at runtime.
2682    ///
2683    /// Validates the settings, updates rate limiters immediately, and stores
2684    /// the new settings. Sub-actor reconfiguration (disk, DHT, NAT) takes
2685    /// effect on next session restart for the fields that remain in
2686    /// `restart_required`; `listen_port`, `dht`, `lsd` are now applied
2687    /// live (M173 Lane B B10).
2688    ///
2689    /// # Errors
2690    ///
2691    /// Returns [`crate::Error::ConcurrentReconfig`] if another
2692    /// `apply_settings` / `apply_settings_classified` call is still in
2693    /// flight (M173 Lane B B11). Caller should retry shortly.
2694    pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2695        let _guard = self
2696            .reconfig_in_flight
2697            .try_lock()
2698            .ok_or(crate::Error::ConcurrentReconfig)?;
2699        let (tx, rx) = oneshot::channel();
2700        self.cmd_tx
2701            .send(SessionCommand::ApplySettings {
2702                settings: Box::new(settings),
2703                reply: tx,
2704            })
2705            .await
2706            .map_err(|_| crate::Error::Shutdown)?;
2707        rx.await.map_err(|_| crate::Error::Shutdown)?
2708    }
2709
2710    /// Apply new settings and return a classification of which fields took
2711    /// effect immediately versus which require a session restart (M171 D3.5).
2712    ///
2713    /// Identical behaviour to [`apply_settings`](Self::apply_settings), but
2714    /// the return value lets callers surface the "restart to apply" UX —
2715    /// specifically the `X-IronTide-Restart-Pending` response header on the
2716    /// qBt v2 `setPreferences` endpoint and the GUI restart-banner.
2717    ///
2718    /// M173 Lane B (B11): the in-flight guard is acquired BEFORE the
2719    /// snapshot read, so concurrent callers cannot race the
2720    /// read-modify-write. The second caller hits
2721    /// [`crate::Error::ConcurrentReconfig`].
2722    ///
2723    /// # Errors
2724    ///
2725    /// Returns [`crate::Error::ConcurrentReconfig`] if another reconfig
2726    /// is still in flight. Returns [`crate::Error::InvalidSettings`] if
2727    /// the patch fails validation. Returns [`crate::Error::Shutdown`]
2728    /// if the session has shut down.
2729    pub async fn apply_settings_classified(
2730        &self,
2731        settings: Settings,
2732    ) -> crate::Result<AppliedSettings> {
2733        let _guard = self
2734            .reconfig_in_flight
2735            .try_lock()
2736            .ok_or(crate::Error::ConcurrentReconfig)?;
2737        // Snapshot AFTER acquiring the guard so the classification is
2738        // computed against a stable pre-call state.
2739        let snapshot = self.settings().await?;
2740        let immediate = classify_immediate(&snapshot, &settings);
2741        let restart_required = classify_restart_required(&snapshot, &settings);
2742        // Inner apply_settings tries to re-acquire the guard. To avoid
2743        // a self-deadlock, we send the command directly here rather
2744        // than calling self.apply_settings(), which would error out
2745        // with ConcurrentReconfig because we already hold the lock.
2746        let (tx, rx) = oneshot::channel();
2747        self.cmd_tx
2748            .send(SessionCommand::ApplySettings {
2749                settings: Box::new(settings),
2750                reply: tx,
2751            })
2752            .await
2753            .map_err(|_| crate::Error::Shutdown)?;
2754        rx.await.map_err(|_| crate::Error::Shutdown)??;
2755        Ok(AppliedSettings {
2756            immediate,
2757            restart_required,
2758        })
2759    }
2760
2761    /// Get the current DHT routing-table size, summed across IPv4 and IPv6
2762    /// instances (M171 D4).
2763    ///
2764    /// Returns `Ok(0)` when DHT is disabled for both address families, or
2765    /// when neither instance has bootstrapped yet. Wired into the qBt v2
2766    /// `transferInfo.dht_nodes` field and the DHT pseudo-tracker's
2767    /// `num_peers` column on `/api/v2/torrents/trackers`.
2768    ///
2769    /// # Errors
2770    ///
2771    /// Returns an error if the session is shut down.
2772    pub async fn dht_node_count(&self) -> crate::Result<usize> {
2773        let (tx, rx) = oneshot::channel();
2774        self.cmd_tx
2775            .send(SessionCommand::DhtNodeCount { reply: tx })
2776            .await
2777            .map_err(|_| crate::Error::Shutdown)?;
2778        rx.await.map_err(|_| crate::Error::Shutdown)
2779    }
2780
2781    /// Get the list of currently banned peer IPs.
2782    ///
2783    /// # Errors
2784    ///
2785    /// Returns an error if the session is shut down.
2786    pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2787        let (tx, rx) = oneshot::channel();
2788        self.cmd_tx
2789            .send(SessionCommand::BannedPeers { reply: tx })
2790            .await
2791            .map_err(|_| crate::Error::Shutdown)?;
2792        rx.await.map_err(|_| crate::Error::Shutdown)
2793    }
2794
2795    /// Move a torrent's data files to a new download directory.
2796    ///
2797    /// # Errors
2798    ///
2799    /// Returns an error if the session is shut down.
2800    pub async fn move_torrent_storage(
2801        &self,
2802        info_hash: Id20,
2803        new_path: std::path::PathBuf,
2804    ) -> crate::Result<()> {
2805        let (tx, rx) = oneshot::channel();
2806        self.cmd_tx
2807            .send(SessionCommand::MoveTorrentStorage {
2808                info_hash,
2809                new_path,
2810                reply: tx,
2811            })
2812            .await
2813            .map_err(|_| crate::Error::Shutdown)?;
2814        rx.await.map_err(|_| crate::Error::Shutdown)?
2815    }
2816
2817    /// Opens a file stream for sequential reading (`AsyncRead` + `AsyncSeek`).
2818    ///
2819    /// The returned [`FileStream`](crate::streaming::FileStream) reads data from
2820    /// a specific file within a torrent, blocking on pieces that haven't been
2821    /// downloaded yet.
2822    ///
2823    /// # Errors
2824    ///
2825    /// Returns an error if the session is shut down.
2826    pub async fn open_file(
2827        &self,
2828        info_hash: Id20,
2829        file_index: usize,
2830    ) -> crate::Result<crate::streaming::FileStream> {
2831        let (tx, rx) = oneshot::channel();
2832        self.cmd_tx
2833            .send(SessionCommand::OpenFile {
2834                info_hash,
2835                file_index,
2836                reply: tx,
2837            })
2838            .await
2839            .map_err(|_| crate::Error::Shutdown)?;
2840        rx.await.map_err(|_| crate::Error::Shutdown)?
2841    }
2842
2843    /// Force all trackers for a torrent to re-announce immediately.
2844    ///
2845    /// # Errors
2846    ///
2847    /// Returns an error if the session is shut down.
2848    pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2849        let (tx, rx) = oneshot::channel();
2850        self.cmd_tx
2851            .send(SessionCommand::ForceReannounce {
2852                info_hash,
2853                reply: tx,
2854            })
2855            .await
2856            .map_err(|_| crate::Error::Shutdown)?;
2857        rx.await.map_err(|_| crate::Error::Shutdown)?
2858    }
2859
2860    /// Get the list of all configured trackers with their status for a torrent.
2861    ///
2862    /// # Errors
2863    ///
2864    /// Returns an error if the session is shut down.
2865    pub async fn tracker_list(
2866        &self,
2867        info_hash: Id20,
2868    ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2869        let (tx, rx) = oneshot::channel();
2870        self.cmd_tx
2871            .send(SessionCommand::TrackerList {
2872                info_hash,
2873                reply: tx,
2874            })
2875            .await
2876            .map_err(|_| crate::Error::Shutdown)?;
2877        rx.await.map_err(|_| crate::Error::Shutdown)?
2878    }
2879
2880    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2881    /// PEX (BEP 11) for this torrent since the actor started.
2882    ///
2883    /// # Errors
2884    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2885    ///   the hash is unknown.
2886    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2887    pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2888        let counts = self.peer_source_counts(info_hash).await?;
2889        Ok(counts.0)
2890    }
2891
2892    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2893    /// LSD (BEP 14) multicast for this torrent since the actor started.
2894    ///
2895    /// # Errors
2896    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2897    ///   the hash is unknown.
2898    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2899    pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2900        let counts = self.peer_source_counts(info_hash).await?;
2901        Ok(counts.1)
2902    }
2903
2904    async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2905        let (tx, rx) = oneshot::channel();
2906        self.cmd_tx
2907            .send(SessionCommand::GetPeerSourceCounts {
2908                info_hash,
2909                reply: tx,
2910            })
2911            .await
2912            .map_err(|_| crate::Error::Shutdown)?;
2913        rx.await.map_err(|_| crate::Error::Shutdown)?
2914    }
2915
2916    /// M178 Lane C: per-URL web-seed stats snapshot for the qBt v2
2917    /// webseeds endpoint and the GUI HTTP Sources tab.
2918    ///
2919    /// # Errors
2920    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2921    ///   the hash is unknown.
2922    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2923    pub async fn web_seed_stats(
2924        &self,
2925        info_hash: Id20,
2926    ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2927        let (tx, rx) = oneshot::channel();
2928        self.cmd_tx
2929            .send(SessionCommand::GetWebSeedStats {
2930                info_hash,
2931                reply: tx,
2932            })
2933            .await
2934            .map_err(|_| crate::Error::Shutdown)?;
2935        rx.await.map_err(|_| crate::Error::Shutdown)?
2936    }
2937
2938    /// M171 Lane B: list the web seed URLs (BEP 19 + BEP 17 merged) for a torrent.
2939    ///
2940    /// BEP 19 `url-list` URLs come first, followed by BEP 17 `httpseeds`
2941    /// URLs — the wire order. Returns an empty vec when the torrent's
2942    /// metadata has not yet been fetched (magnet still resolving the
2943    /// info dictionary).
2944    ///
2945    /// # Errors
2946    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2947    ///   info-hash is unknown.
2948    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2949    ///   command channel has closed.
2950    pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
2951        let (tx, rx) = oneshot::channel();
2952        self.cmd_tx
2953            .send(SessionCommand::GetWebSeeds {
2954                info_hash,
2955                reply: tx,
2956            })
2957            .await
2958            .map_err(|_| crate::Error::Shutdown)?;
2959        rx.await.map_err(|_| crate::Error::Shutdown)?
2960    }
2961
2962    /// M171 Lane B: snapshot the per-piece qBt state codes for a torrent.
2963    ///
2964    /// Returns a `Vec<u8>` one byte per piece — `0` = not downloaded,
2965    /// `1` = downloading, `2` = downloaded + checked. An empty vec is
2966    /// returned when metadata hasn't resolved yet (magnet still fetching
2967    /// the info dictionary) — callers should map that to a 404.
2968    ///
2969    /// # Errors
2970    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2971    ///   info-hash is unknown.
2972    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2973    ///   command channel has closed.
2974    pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
2975        let (tx, rx) = oneshot::channel();
2976        self.cmd_tx
2977            .send(SessionCommand::GetPieceStates {
2978                info_hash,
2979                reply: tx,
2980            })
2981            .await
2982            .map_err(|_| crate::Error::Shutdown)?;
2983        rx.await.map_err(|_| crate::Error::Shutdown)?
2984    }
2985
2986    /// M171 Lane B: paginated piece hash list for a torrent.
2987    ///
2988    /// v1 / hybrid torrents return SHA-1 hashes (40-char hex strings);
2989    /// v2-only torrents return SHA-256 hashes (64-char hex strings).
2990    /// `offset` and `limit` are clamped to the real hash count inside
2991    /// the actor — callers can pass arbitrary values without overflow
2992    /// concerns. An empty vec is returned when metadata hasn't resolved
2993    /// yet — callers should map that to a 404.
2994    ///
2995    /// # Errors
2996    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2997    ///   info-hash is unknown.
2998    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2999    ///   command channel has closed.
3000    pub async fn get_piece_hashes(
3001        &self,
3002        info_hash: Id20,
3003        offset: u32,
3004        limit: u32,
3005    ) -> crate::Result<Vec<String>> {
3006        let (tx, rx) = oneshot::channel();
3007        self.cmd_tx
3008            .send(SessionCommand::GetPieceHashes {
3009                info_hash,
3010                offset,
3011                limit,
3012                reply: tx,
3013            })
3014            .await
3015            .map_err(|_| crate::Error::Shutdown)?;
3016        rx.await.map_err(|_| crate::Error::Shutdown)?
3017    }
3018
3019    /// Scrape trackers for seeder/leecher counts for a torrent.
3020    ///
3021    /// # Errors
3022    ///
3023    /// Returns an error if the session is shut down.
3024    pub async fn scrape(
3025        &self,
3026        info_hash: Id20,
3027    ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
3028        let (tx, rx) = oneshot::channel();
3029        self.cmd_tx
3030            .send(SessionCommand::Scrape {
3031                info_hash,
3032                reply: tx,
3033            })
3034            .await
3035            .map_err(|_| crate::Error::Shutdown)?;
3036        rx.await.map_err(|_| crate::Error::Shutdown)?
3037    }
3038
3039    /// Set the download priority of a specific file within a torrent.
3040    ///
3041    /// # Errors
3042    ///
3043    /// Returns an error if the session is shut down.
3044    pub async fn set_file_priority(
3045        &self,
3046        info_hash: Id20,
3047        index: usize,
3048        priority: irontide_core::FilePriority,
3049    ) -> crate::Result<()> {
3050        let (tx, rx) = oneshot::channel();
3051        self.cmd_tx
3052            .send(SessionCommand::SetFilePriority {
3053                info_hash,
3054                index,
3055                priority,
3056                reply: tx,
3057            })
3058            .await
3059            .map_err(|_| crate::Error::Shutdown)?;
3060        rx.await.map_err(|_| crate::Error::Shutdown)?
3061    }
3062
3063    /// Get the current per-file priorities for a torrent.
3064    ///
3065    /// # Errors
3066    ///
3067    /// Returns an error if the session is shut down.
3068    pub async fn file_priorities(
3069        &self,
3070        info_hash: Id20,
3071    ) -> crate::Result<Vec<irontide_core::FilePriority>> {
3072        let (tx, rx) = oneshot::channel();
3073        self.cmd_tx
3074            .send(SessionCommand::FilePriorities {
3075                info_hash,
3076                reply: tx,
3077            })
3078            .await
3079            .map_err(|_| crate::Error::Shutdown)?;
3080        rx.await.map_err(|_| crate::Error::Shutdown)?
3081    }
3082
3083    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
3084    ///
3085    /// # Errors
3086    ///
3087    /// Returns an error if the data cannot be parsed or I/O fails.
3088    pub async fn set_download_limit(
3089        &self,
3090        info_hash: Id20,
3091        bytes_per_sec: u64,
3092    ) -> crate::Result<()> {
3093        let (tx, rx) = oneshot::channel();
3094        self.cmd_tx
3095            .send(SessionCommand::SetDownloadLimit {
3096                info_hash,
3097                bytes_per_sec,
3098                reply: tx,
3099            })
3100            .await
3101            .map_err(|_| crate::Error::Shutdown)?;
3102        rx.await.map_err(|_| crate::Error::Shutdown)?
3103    }
3104
3105    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
3106    ///
3107    /// # Errors
3108    ///
3109    /// Returns an error if the data cannot be parsed or I/O fails.
3110    pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
3111        let (tx, rx) = oneshot::channel();
3112        self.cmd_tx
3113            .send(SessionCommand::SetUploadLimit {
3114                info_hash,
3115                bytes_per_sec,
3116                reply: tx,
3117            })
3118            .await
3119            .map_err(|_| crate::Error::Shutdown)?;
3120        rx.await.map_err(|_| crate::Error::Shutdown)?
3121    }
3122
3123    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
3124    ///
3125    /// # Errors
3126    ///
3127    /// Returns an error if the data cannot be parsed or I/O fails.
3128    pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3129        let (tx, rx) = oneshot::channel();
3130        self.cmd_tx
3131            .send(SessionCommand::DownloadLimit {
3132                info_hash,
3133                reply: tx,
3134            })
3135            .await
3136            .map_err(|_| crate::Error::Shutdown)?;
3137        rx.await.map_err(|_| crate::Error::Shutdown)?
3138    }
3139
3140    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
3141    ///
3142    /// # Errors
3143    ///
3144    /// Returns an error if the data cannot be parsed or I/O fails.
3145    pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3146        let (tx, rx) = oneshot::channel();
3147        self.cmd_tx
3148            .send(SessionCommand::UploadLimit {
3149                info_hash,
3150                reply: tx,
3151            })
3152            .await
3153            .map_err(|_| crate::Error::Shutdown)?;
3154        rx.await.map_err(|_| crate::Error::Shutdown)?
3155    }
3156
3157    /// Enable or disable sequential (in-order) piece downloading for a torrent.
3158    ///
3159    /// # Errors
3160    ///
3161    /// Returns an error if the data cannot be parsed or I/O fails.
3162    pub async fn set_sequential_download(
3163        &self,
3164        info_hash: Id20,
3165        enabled: bool,
3166    ) -> crate::Result<()> {
3167        let (tx, rx) = oneshot::channel();
3168        self.cmd_tx
3169            .send(SessionCommand::SetSequentialDownload {
3170                info_hash,
3171                enabled,
3172                reply: tx,
3173            })
3174            .await
3175            .map_err(|_| crate::Error::Shutdown)?;
3176        rx.await.map_err(|_| crate::Error::Shutdown)?
3177    }
3178
3179    /// Query whether sequential downloading is enabled for a torrent.
3180    ///
3181    /// # Errors
3182    ///
3183    /// Returns an error if the data cannot be parsed or I/O fails.
3184    pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3185        let (tx, rx) = oneshot::channel();
3186        self.cmd_tx
3187            .send(SessionCommand::IsSequentialDownload {
3188                info_hash,
3189                reply: tx,
3190            })
3191            .await
3192            .map_err(|_| crate::Error::Shutdown)?;
3193        rx.await.map_err(|_| crate::Error::Shutdown)?
3194    }
3195
3196    /// Enable or disable BEP 16 super seeding mode for a torrent.
3197    ///
3198    /// # Errors
3199    ///
3200    /// Returns an error if the session is shut down.
3201    pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3202        let (tx, rx) = oneshot::channel();
3203        self.cmd_tx
3204            .send(SessionCommand::SetSuperSeeding {
3205                info_hash,
3206                enabled,
3207                reply: tx,
3208            })
3209            .await
3210            .map_err(|_| crate::Error::Shutdown)?;
3211        rx.await.map_err(|_| crate::Error::Shutdown)?
3212    }
3213
3214    /// Query whether BEP 16 super seeding mode is enabled for a torrent.
3215    ///
3216    /// # Errors
3217    ///
3218    /// Returns an error if the session is shut down.
3219    pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3220        let (tx, rx) = oneshot::channel();
3221        self.cmd_tx
3222            .send(SessionCommand::IsSuperSeeding {
3223                info_hash,
3224                reply: tx,
3225            })
3226            .await
3227            .map_err(|_| crate::Error::Shutdown)?;
3228        rx.await.map_err(|_| crate::Error::Shutdown)?
3229    }
3230
3231    /// Enable or disable user-requested seed-only mode for a torrent (M159).
3232    ///
3233    /// When `enabled` is `true`, the engine stops scheduling new block requests
3234    /// and cancels all in-flight requests for the torrent, but continues to
3235    /// serve uploads to interested peers. This is distinct from "naturally
3236    /// seeding" (all pieces downloaded): it represents an explicit user toggle
3237    /// layered on top of the download state.
3238    ///
3239    /// Toggling back to `false` resumes normal piece scheduling.
3240    ///
3241    /// # Errors
3242    ///
3243    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` is not registered
3244    /// in the session, or [`crate::Error::Shutdown`] if the session actor has
3245    /// terminated.
3246    pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3247        let (tx, rx) = oneshot::channel();
3248        self.cmd_tx
3249            .send(SessionCommand::SetSeedMode {
3250                info_hash,
3251                enabled,
3252                reply: tx,
3253            })
3254            .await
3255            .map_err(|_| crate::Error::Shutdown)?;
3256        rx.await.map_err(|_| crate::Error::Shutdown)?
3257    }
3258
3259    /// Add a new tracker URL to a torrent.
3260    ///
3261    /// The URL is validated and deduplicated by the tracker manager.
3262    ///
3263    /// # Errors
3264    ///
3265    /// Returns an error if the session is shut down.
3266    pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3267        let (tx, rx) = oneshot::channel();
3268        self.cmd_tx
3269            .send(SessionCommand::AddTracker {
3270                info_hash,
3271                url,
3272                reply: tx,
3273            })
3274            .await
3275            .map_err(|_| crate::Error::Shutdown)?;
3276        rx.await.map_err(|_| crate::Error::Shutdown)?
3277    }
3278
3279    /// Replace all tracker URLs for a torrent.
3280    ///
3281    /// # Errors
3282    ///
3283    /// Returns an error if the session is shut down.
3284    pub async fn replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3285        let (tx, rx) = oneshot::channel();
3286        self.cmd_tx
3287            .send(SessionCommand::ReplaceTrackers {
3288                info_hash,
3289                urls,
3290                reply: tx,
3291            })
3292            .await
3293            .map_err(|_| crate::Error::Shutdown)?;
3294        rx.await.map_err(|_| crate::Error::Shutdown)?
3295    }
3296
3297    /// Trigger a full piece verification (force recheck) for a torrent.
3298    ///
3299    /// Clears all piece completion data, re-verifies every piece, and
3300    /// transitions to `Seeding` or `Downloading` depending on the result.
3301    /// Returns after the recheck is complete.
3302    ///
3303    /// # Errors
3304    ///
3305    /// Returns an error if the session is shut down.
3306    pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3307        let (tx, rx) = oneshot::channel();
3308        self.cmd_tx
3309            .send(SessionCommand::ForceRecheck {
3310                info_hash,
3311                reply: tx,
3312            })
3313            .await
3314            .map_err(|_| crate::Error::Shutdown)?;
3315        rx.await.map_err(|_| crate::Error::Shutdown)?
3316    }
3317
3318    /// Rename a file within a torrent on disk.
3319    ///
3320    /// Changes the filename of the specified file (by index) to `new_name`.
3321    /// The file stays in the same directory; only the filename component changes.
3322    /// Fires a `FileRenamed` alert on success.
3323    ///
3324    /// # Errors
3325    ///
3326    /// Returns an error if the session is shut down.
3327    pub async fn rename_file(
3328        &self,
3329        info_hash: Id20,
3330        file_index: usize,
3331        new_name: String,
3332    ) -> crate::Result<()> {
3333        let (tx, rx) = oneshot::channel();
3334        self.cmd_tx
3335            .send(SessionCommand::RenameFile {
3336                info_hash,
3337                file_index,
3338                new_name,
3339                reply: tx,
3340            })
3341            .await
3342            .map_err(|_| crate::Error::Shutdown)?;
3343        rx.await.map_err(|_| crate::Error::Shutdown)?
3344    }
3345
3346    /// Set the per-torrent maximum number of connections (0 = use global default).
3347    ///
3348    /// # Errors
3349    ///
3350    /// Returns an error if the connection or binding fails.
3351    pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3352        let (tx, rx) = oneshot::channel();
3353        self.cmd_tx
3354            .send(SessionCommand::SetMaxConnections {
3355                info_hash,
3356                limit,
3357                reply: tx,
3358            })
3359            .await
3360            .map_err(|_| crate::Error::Shutdown)?;
3361        rx.await.map_err(|_| crate::Error::Shutdown)?
3362    }
3363
3364    /// Get the current per-torrent maximum connection limit (0 = use global default).
3365    ///
3366    /// # Errors
3367    ///
3368    /// Returns an error if the connection or binding fails.
3369    pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3370        let (tx, rx) = oneshot::channel();
3371        self.cmd_tx
3372            .send(SessionCommand::MaxConnections {
3373                info_hash,
3374                reply: tx,
3375            })
3376            .await
3377            .map_err(|_| crate::Error::Shutdown)?;
3378        rx.await.map_err(|_| crate::Error::Shutdown)?
3379    }
3380
3381    /// Set the per-torrent maximum number of upload slots (unchoke slots).
3382    ///
3383    /// # Errors
3384    ///
3385    /// Returns an error if the data cannot be parsed or I/O fails.
3386    pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3387        let (tx, rx) = oneshot::channel();
3388        self.cmd_tx
3389            .send(SessionCommand::SetMaxUploads {
3390                info_hash,
3391                limit,
3392                reply: tx,
3393            })
3394            .await
3395            .map_err(|_| crate::Error::Shutdown)?;
3396        rx.await.map_err(|_| crate::Error::Shutdown)?
3397    }
3398
3399    /// Get the current per-torrent maximum upload slots (unchoke slots).
3400    ///
3401    /// # Errors
3402    ///
3403    /// Returns an error if the data cannot be parsed or I/O fails.
3404    pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3405        let (tx, rx) = oneshot::channel();
3406        self.cmd_tx
3407            .send(SessionCommand::MaxUploads {
3408                info_hash,
3409                reply: tx,
3410            })
3411            .await
3412            .map_err(|_| crate::Error::Shutdown)?;
3413        rx.await.map_err(|_| crate::Error::Shutdown)?
3414    }
3415
3416    /// Get per-peer details for all connected peers of a torrent.
3417    ///
3418    /// # Errors
3419    ///
3420    /// Returns an error if the session is shut down.
3421    pub async fn get_peer_info(
3422        &self,
3423        info_hash: Id20,
3424    ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3425        let (tx, rx) = oneshot::channel();
3426        self.cmd_tx
3427            .send(SessionCommand::GetPeerInfo {
3428                info_hash,
3429                reply: tx,
3430            })
3431            .await
3432            .map_err(|_| crate::Error::Shutdown)?;
3433        rx.await.map_err(|_| crate::Error::Shutdown)?
3434    }
3435
3436    /// Get in-flight piece download status for a torrent (the download queue).
3437    ///
3438    /// # Errors
3439    ///
3440    /// Returns an error if the data cannot be parsed or I/O fails.
3441    pub async fn get_download_queue(
3442        &self,
3443        info_hash: Id20,
3444    ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3445        let (tx, rx) = oneshot::channel();
3446        self.cmd_tx
3447            .send(SessionCommand::GetDownloadQueue {
3448                info_hash,
3449                reply: tx,
3450            })
3451            .await
3452            .map_err(|_| crate::Error::Shutdown)?;
3453        rx.await.map_err(|_| crate::Error::Shutdown)?
3454    }
3455
3456    /// Check whether a specific piece has been downloaded for a torrent.
3457    ///
3458    /// # Errors
3459    ///
3460    /// Returns an error if the session is shut down.
3461    pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3462        let (tx, rx) = oneshot::channel();
3463        self.cmd_tx
3464            .send(SessionCommand::HavePiece {
3465                info_hash,
3466                index,
3467                reply: tx,
3468            })
3469            .await
3470            .map_err(|_| crate::Error::Shutdown)?;
3471        rx.await.map_err(|_| crate::Error::Shutdown)?
3472    }
3473
3474    /// Get per-piece availability counts from connected peers for a torrent.
3475    ///
3476    /// # Errors
3477    ///
3478    /// Returns an error if the session is shut down.
3479    pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3480        let (tx, rx) = oneshot::channel();
3481        self.cmd_tx
3482            .send(SessionCommand::PieceAvailability {
3483                info_hash,
3484                reply: tx,
3485            })
3486            .await
3487            .map_err(|_| crate::Error::Shutdown)?;
3488        rx.await.map_err(|_| crate::Error::Shutdown)?
3489    }
3490
3491    /// Get per-file bytes-downloaded progress for a torrent.
3492    ///
3493    /// # Errors
3494    ///
3495    /// Returns an error if the session is shut down.
3496    pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3497        let (tx, rx) = oneshot::channel();
3498        self.cmd_tx
3499            .send(SessionCommand::FileProgress {
3500                info_hash,
3501                reply: tx,
3502            })
3503            .await
3504            .map_err(|_| crate::Error::Shutdown)?;
3505        rx.await.map_err(|_| crate::Error::Shutdown)?
3506    }
3507
3508    /// Get the torrent's identity hashes (v1 and/or v2).
3509    ///
3510    /// # Errors
3511    ///
3512    /// Returns an error if the session is shut down.
3513    pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3514        let (tx, rx) = oneshot::channel();
3515        self.cmd_tx
3516            .send(SessionCommand::InfoHashesQuery {
3517                info_hash,
3518                reply: tx,
3519            })
3520            .await
3521            .map_err(|_| crate::Error::Shutdown)?;
3522        rx.await.map_err(|_| crate::Error::Shutdown)?
3523    }
3524
3525    /// Get the full v1 metainfo for a torrent.
3526    ///
3527    /// Returns `None` for magnet links before metadata has been received.
3528    ///
3529    /// # Errors
3530    ///
3531    /// Returns an error if the session is shut down.
3532    pub async fn torrent_file(
3533        &self,
3534        info_hash: Id20,
3535    ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3536        let (tx, rx) = oneshot::channel();
3537        self.cmd_tx
3538            .send(SessionCommand::TorrentFile {
3539                info_hash,
3540                reply: tx,
3541            })
3542            .await
3543            .map_err(|_| crate::Error::Shutdown)?;
3544        rx.await.map_err(|_| crate::Error::Shutdown)?
3545    }
3546
3547    /// Get the full v2 metainfo for a torrent.
3548    ///
3549    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
3550    /// links before metadata has been received.
3551    ///
3552    /// # Errors
3553    ///
3554    /// Returns an error if the session is shut down.
3555    pub async fn torrent_file_v2(
3556        &self,
3557        info_hash: Id20,
3558    ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3559        let (tx, rx) = oneshot::channel();
3560        self.cmd_tx
3561            .send(SessionCommand::TorrentFileV2 {
3562                info_hash,
3563                reply: tx,
3564            })
3565            .await
3566            .map_err(|_| crate::Error::Shutdown)?;
3567        rx.await.map_err(|_| crate::Error::Shutdown)?
3568    }
3569
3570    /// **TEST-ONLY.** Synchronously inject an info-dict into a torrent's
3571    /// `MetadataDownloader` queue. Returns only after the actor has processed
3572    /// the message. Reuses the existing M147 internal handler at
3573    /// `torrent.rs:3665` via a separate test-only `TorrentCommand` variant
3574    /// (the production [`TorrentHandle::send_pre_resolved_metadata`] is
3575    /// fire-and-forget and stays unchanged).
3576    ///
3577    /// Introduced in v0.173.2 as a cross-crate test escape hatch for the
3578    /// `irontide-api` A9 HTTP integration test — it lets tests exercise the
3579    /// post-metadata HTTP surface without spinning up real peers.
3580    ///
3581    /// # Errors
3582    /// - [`crate::Error::TorrentNotFound`] if `info_hash` is not registered.
3583    /// - [`crate::Error::Shutdown`] if the session or torrent command
3584    ///   channel is closed.
3585    #[cfg(feature = "test-util")]
3586    pub async fn debug_inject_metadata(
3587        &self,
3588        info_hash: Id20,
3589        info_bytes: Vec<u8>,
3590    ) -> crate::Result<()> {
3591        let (tx, rx) = oneshot::channel();
3592        self.cmd_tx
3593            .send(SessionCommand::TestInjectMetadata {
3594                info_hash,
3595                info_bytes,
3596                reply: tx,
3597            })
3598            .await
3599            .map_err(|_| crate::Error::Shutdown)?;
3600        rx.await.map_err(|_| crate::Error::Shutdown)?
3601    }
3602
3603    /// Force an immediate DHT announce for a torrent.
3604    ///
3605    /// # Errors
3606    ///
3607    /// Returns an error if the session is shut down.
3608    pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3609        let (tx, rx) = oneshot::channel();
3610        self.cmd_tx
3611            .send(SessionCommand::ForceDhtAnnounce {
3612                info_hash,
3613                reply: tx,
3614            })
3615            .await
3616            .map_err(|_| crate::Error::Shutdown)?;
3617        rx.await.map_err(|_| crate::Error::Shutdown)?
3618    }
3619
3620    /// Force an immediate LSD (Local Service Discovery) announce for a torrent.
3621    ///
3622    /// LSD is a session-level component — this does not go through the torrent actor.
3623    ///
3624    /// # Errors
3625    ///
3626    /// Returns an error if the session is shut down.
3627    pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3628        let (tx, rx) = oneshot::channel();
3629        self.cmd_tx
3630            .send(SessionCommand::ForceLsdAnnounce {
3631                info_hash,
3632                reply: tx,
3633            })
3634            .await
3635            .map_err(|_| crate::Error::Shutdown)?;
3636        rx.await.map_err(|_| crate::Error::Shutdown)?
3637    }
3638
3639    /// Read all data for a specific piece from disk.
3640    ///
3641    /// # Errors
3642    ///
3643    /// Returns an error if the data cannot be parsed or I/O fails.
3644    pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3645        let (tx, rx) = oneshot::channel();
3646        self.cmd_tx
3647            .send(SessionCommand::ReadPiece {
3648                info_hash,
3649                index,
3650                reply: tx,
3651            })
3652            .await
3653            .map_err(|_| crate::Error::Shutdown)?;
3654        rx.await.map_err(|_| crate::Error::Shutdown)?
3655    }
3656
3657    /// Flush the disk write cache for a torrent.
3658    ///
3659    /// # Errors
3660    ///
3661    /// Returns an error if the session is shut down.
3662    pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3663        let (tx, rx) = oneshot::channel();
3664        self.cmd_tx
3665            .send(SessionCommand::FlushCache {
3666                info_hash,
3667                reply: tx,
3668            })
3669            .await
3670            .map_err(|_| crate::Error::Shutdown)?;
3671        rx.await.map_err(|_| crate::Error::Shutdown)?
3672    }
3673
3674    /// Check if a torrent exists in the session and its handle is still valid.
3675    pub async fn is_valid(&self, info_hash: Id20) -> bool {
3676        let (tx, rx) = oneshot::channel();
3677        if self
3678            .cmd_tx
3679            .send(SessionCommand::IsValid {
3680                info_hash,
3681                reply: tx,
3682            })
3683            .await
3684            .is_err()
3685        {
3686            return false;
3687        }
3688        rx.await.unwrap_or(false)
3689    }
3690
3691    /// Clear the error state on a torrent, resuming it if it was paused due to error.
3692    ///
3693    /// # Errors
3694    ///
3695    /// Returns an error if the session is shut down.
3696    pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3697        let (tx, rx) = oneshot::channel();
3698        self.cmd_tx
3699            .send(SessionCommand::ClearError {
3700                info_hash,
3701                reply: tx,
3702            })
3703            .await
3704            .map_err(|_| crate::Error::Shutdown)?;
3705        rx.await.map_err(|_| crate::Error::Shutdown)?
3706    }
3707
3708    /// Get per-file open/mode status for a torrent.
3709    ///
3710    /// # Errors
3711    ///
3712    /// Returns an error if the session is shut down.
3713    pub async fn file_status(
3714        &self,
3715        info_hash: Id20,
3716    ) -> crate::Result<Vec<crate::types::FileStatus>> {
3717        let (tx, rx) = oneshot::channel();
3718        self.cmd_tx
3719            .send(SessionCommand::FileStatus {
3720                info_hash,
3721                reply: tx,
3722            })
3723            .await
3724            .map_err(|_| crate::Error::Shutdown)?;
3725        rx.await.map_err(|_| crate::Error::Shutdown)?
3726    }
3727
3728    /// Read the current torrent flags as a [`crate::types::TorrentFlags`] bitflag set.
3729    ///
3730    /// # Errors
3731    ///
3732    /// Returns an error if the session is shut down.
3733    pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3734        let (tx, rx) = oneshot::channel();
3735        self.cmd_tx
3736            .send(SessionCommand::Flags {
3737                info_hash,
3738                reply: tx,
3739            })
3740            .await
3741            .map_err(|_| crate::Error::Shutdown)?;
3742        rx.await.map_err(|_| crate::Error::Shutdown)?
3743    }
3744
3745    /// Set (enable) the specified torrent flags.
3746    ///
3747    /// # Errors
3748    ///
3749    /// Returns an error if the session is shut down.
3750    pub async fn set_flags(
3751        &self,
3752        info_hash: Id20,
3753        flags: crate::types::TorrentFlags,
3754    ) -> crate::Result<()> {
3755        let (tx, rx) = oneshot::channel();
3756        self.cmd_tx
3757            .send(SessionCommand::SetFlags {
3758                info_hash,
3759                flags,
3760                reply: tx,
3761            })
3762            .await
3763            .map_err(|_| crate::Error::Shutdown)?;
3764        rx.await.map_err(|_| crate::Error::Shutdown)?
3765    }
3766
3767    /// Unset (disable) the specified torrent flags.
3768    ///
3769    /// # Errors
3770    ///
3771    /// Returns an error if the session is shut down.
3772    pub async fn unset_flags(
3773        &self,
3774        info_hash: Id20,
3775        flags: crate::types::TorrentFlags,
3776    ) -> crate::Result<()> {
3777        let (tx, rx) = oneshot::channel();
3778        self.cmd_tx
3779            .send(SessionCommand::UnsetFlags {
3780                info_hash,
3781                flags,
3782                reply: tx,
3783            })
3784            .await
3785            .map_err(|_| crate::Error::Shutdown)?;
3786        rx.await.map_err(|_| crate::Error::Shutdown)?
3787    }
3788
3789    /// Immediately initiate a peer connection for a torrent.
3790    ///
3791    /// # Errors
3792    ///
3793    /// Returns an error if the connection or binding fails.
3794    pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3795        let (tx, rx) = oneshot::channel();
3796        self.cmd_tx
3797            .send(SessionCommand::ConnectPeer {
3798                info_hash,
3799                addr,
3800                reply: tx,
3801            })
3802            .await
3803            .map_err(|_| crate::Error::Shutdown)?;
3804        rx.await.map_err(|_| crate::Error::Shutdown)?
3805    }
3806
3807    /// Store an immutable item in the DHT (BEP 44).
3808    ///
3809    /// Returns the SHA-1 target hash of the stored value.
3810    ///
3811    /// # Errors
3812    ///
3813    /// Returns an error if the session is shut down.
3814    pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3815        let (tx, rx) = oneshot::channel();
3816        self.cmd_tx
3817            .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3818            .await
3819            .map_err(|_| crate::Error::Shutdown)?;
3820        rx.await.map_err(|_| crate::Error::Shutdown)?
3821    }
3822
3823    /// Retrieve an immutable item from the DHT (BEP 44).
3824    ///
3825    /// Returns `Some(value)` if found, `None` otherwise.
3826    ///
3827    /// # Errors
3828    ///
3829    /// Returns an error if the session is shut down.
3830    pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3831        let (tx, rx) = oneshot::channel();
3832        self.cmd_tx
3833            .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3834            .await
3835            .map_err(|_| crate::Error::Shutdown)?;
3836        rx.await.map_err(|_| crate::Error::Shutdown)?
3837    }
3838
3839    /// Store a mutable item in the DHT (BEP 44).
3840    ///
3841    /// `keypair_bytes` is a 32-byte Ed25519 seed. Returns the target hash.
3842    ///
3843    /// # Errors
3844    ///
3845    /// Returns an error if the session is shut down.
3846    pub async fn dht_put_mutable(
3847        &self,
3848        keypair_bytes: [u8; 32],
3849        value: Vec<u8>,
3850        seq: i64,
3851        salt: Vec<u8>,
3852    ) -> crate::Result<Id20> {
3853        let (tx, rx) = oneshot::channel();
3854        self.cmd_tx
3855            .send(SessionCommand::DhtPutMutable {
3856                keypair_bytes,
3857                value,
3858                seq,
3859                salt,
3860                reply: tx,
3861            })
3862            .await
3863            .map_err(|_| crate::Error::Shutdown)?;
3864        rx.await.map_err(|_| crate::Error::Shutdown)?
3865    }
3866
3867    /// Retrieve a mutable item from the DHT (BEP 44).
3868    ///
3869    /// Returns `Some((value, seq))` if found, `None` otherwise.
3870    ///
3871    /// # Errors
3872    ///
3873    /// Returns an error if the session is shut down.
3874    pub async fn dht_get_mutable(
3875        &self,
3876        public_key: [u8; 32],
3877        salt: Vec<u8>,
3878    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3879        let (tx, rx) = oneshot::channel();
3880        self.cmd_tx
3881            .send(SessionCommand::DhtGetMutable {
3882                public_key,
3883                salt,
3884                reply: tx,
3885            })
3886            .await
3887            .map_err(|_| crate::Error::Shutdown)?;
3888        rx.await.map_err(|_| crate::Error::Shutdown)?
3889    }
3890
3891    // ── M121: Convenience API for future HTTP endpoints ──
3892
3893    /// List all torrents as lightweight summaries.
3894    ///
3895    /// Fetches stats for each active torrent and converts to [`TorrentSummary`].
3896    /// Torrents that fail the stats query (e.g. shutting down) are silently skipped.
3897    ///
3898    /// # Errors
3899    ///
3900    /// Returns an error if the session is shut down.
3901    pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
3902        let ids = self.list_torrents().await?;
3903        let mut summaries = Vec::with_capacity(ids.len());
3904        for id in ids {
3905            if let Ok(stats) = self.torrent_stats(id).await {
3906                summaries.push(TorrentSummary::from(&stats));
3907            }
3908        }
3909        Ok(summaries)
3910    }
3911
3912    /// Add a torrent from a magnet URI string.
3913    ///
3914    /// Parses the URI, extracts info hashes, and adds the magnet to the session.
3915    /// Returns the info hashes (v1 and/or v2) for the added torrent.
3916    ///
3917    /// # Errors
3918    ///
3919    /// Returns an error if the torrent cannot be added or the session is shut down.
3920    pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
3921        let magnet = irontide_core::Magnet::parse(uri)?;
3922        let info_hashes = magnet.info_hashes.clone();
3923        self.add_magnet(magnet).await?;
3924        Ok(info_hashes)
3925    }
3926
3927    /// Add a torrent from raw .torrent file bytes.
3928    ///
3929    /// Auto-detects v1, v2, or hybrid format. Returns the info hashes for the
3930    /// added torrent.
3931    ///
3932    /// # Errors
3933    ///
3934    /// Returns an error if the torrent cannot be added or the session is shut down.
3935    pub async fn add_torrent_bytes(
3936        &self,
3937        bytes: &[u8],
3938    ) -> crate::Result<irontide_core::InfoHashes> {
3939        let meta = irontide_core::torrent_from_bytes_any(bytes)?;
3940        let info_hashes = meta.info_hashes();
3941        self.add_torrent_with_meta(meta, None).await?;
3942        Ok(info_hashes)
3943    }
3944}
3945
3946// ---------------------------------------------------------------------------
3947// SessionActor — internal single-owner event loop
3948// ---------------------------------------------------------------------------
3949
3950struct SessionActor {
3951    settings: Settings,
3952    /// M223 — self-loopback sender, so the spawned `handle_add_torrent`
3953    /// prep tasks can route their `CommitAddTorrent` result back through
3954    /// the same recv loop that owns the mutating session state.
3955    /// Cloned from the same sender the `SessionHandle` holds; carries
3956    /// the M221.1a timing wrapper so the commit hop is observable in
3957    /// the `queue_wait_ms` / `handler_ms` telemetry.
3958    commit_tx: SessionCmdSender,
3959    torrents: HashMap<Id20, TorrentEntry>,
3960    dht_v4: Option<DhtHandle>,
3961    dht_v6: Option<DhtHandle>,
3962    /// M173 Lane B (B6): broadcast surface for runtime `DhtHandle`
3963    /// replacement. `TorrentActor` consumers borrow a `DhtReceiver`
3964    /// instead of cloning `DhtHandle`, so a session-side DHT restart
3965    /// (B11) propagates to every torrent on the next `borrow()`.
3966    /// Initialised once at session start with the same value as the
3967    /// `dht_v4`/`dht_v6` clones; never written until B11.
3968    dht_v4_broadcast: irontide_dht::DhtBroadcast,
3969    dht_v6_broadcast: irontide_dht::DhtBroadcast,
3970    lsd: Option<crate::lsd::LsdHandle>,
3971    lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
3972    cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
3973    alert_tx: broadcast::Sender<Alert>,
3974    alert_mask: Arc<AtomicU32>,
3975    global_upload_bucket: SharedBucket,
3976    global_download_bucket: SharedBucket,
3977    utp_socket: Option<irontide_utp::UtpSocket>,
3978    utp_socket_v6: Option<irontide_utp::UtpSocket>,
3979    nat: Option<irontide_nat::NatHandle>,
3980    nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
3981    ban_manager: SharedBanManager,
3982    ip_filter: SharedIpFilter,
3983    disk_manager: crate::disk::DiskManagerHandle,
3984    #[allow(dead_code)]
3985    disk_actor_handle: tokio::task::JoinHandle<()>,
3986    /// External IP discovered via NAT traversal or configured manually (BEP 40).
3987    external_ip: Option<std::net::IpAddr>,
3988    /// BEP 42: External IP consensus from DHT v4 KRPC responses.
3989    dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3990    /// BEP 42: External IP consensus from DHT v6 KRPC responses.
3991    dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3992    /// Registered extension plugins, shared with all `TorrentActors`.
3993    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
3994    /// I2P SAM session (if enabled).
3995    sam_session: Option<Arc<crate::i2p::SamSession>>,
3996    /// SSL manager for SSL torrent certificate handling (M42).
3997    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
3998    /// SSL/TLS TCP listener (separate port from the main listener) (M42).
3999    ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
4000    /// Channel receiving pre-validated inbound connections from the `ListenerTask` (M114).
4001    validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
4002    /// Registry of active info hashes shared with the `ListenerTask` (M114).
4003    /// INVARIANT: Must be updated whenever torrents are added/removed.
4004    /// If a new torrent-add path is added without updating this registry,
4005    /// inbound connections for that torrent will be silently rejected.
4006    info_hash_registry: Arc<DashMap<Id20, ()>>,
4007    /// Handle to keep the listener task alive; dropped on session shutdown
4008    /// (M114). M173 Lane B (B2) replaced the bare `JoinHandle` with
4009    /// `ListenerHandle` so the listen-port rebind path (B4) can call
4010    /// `shutdown_with_timeout` for a clean port swap.
4011    #[allow(dead_code)] // used by Drop sequence + B4 listen-port rebind
4012    _listener_task: crate::listener::ListenerHandle,
4013    /// M224 D3: global TCP-inbound connection cap, shared with the
4014    /// `ListenerTask`. `-1` = unlimited. Updated atomically from
4015    /// `handle_apply_settings` so the listener sees the new value on the
4016    /// next accept without restarting the task.
4017    max_connections_global: Arc<std::sync::atomic::AtomicI32>,
4018    /// M224 D3: live TCP-inbound connection count, shared with the
4019    /// `ListenerTask`. Incremented at TCP accept; decremented when the
4020    /// `LiveConnectionGuard` attached to the accepted connection drops.
4021    /// Kept here so future milestones (M225 outbound, observability) can
4022    /// read the live count without touching the listener.
4023    #[allow(dead_code)] // read via the M224 G1 integration test + future M225 observability.
4024    live_connections: Arc<std::sync::atomic::AtomicUsize>,
4025    /// Shared atomic session counters (M50).
4026    counters: Arc<crate::stats::SessionCounters>,
4027    /// Network transport factory for TCP operations (M51).
4028    factory: Arc<crate::transport::NetworkFactory>,
4029    /// Shared hash pool for parallel piece verification (M96).
4030    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
4031    /// M170: qBt-compat category registry, shared with CRUD call paths.
4032    category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
4033    /// M171: qBt-compat tag registry, shared with CRUD call paths.
4034    tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
4035    /// M170: info hashes currently in the `remove_torrent_with_files`
4036    /// grace period. Guards against fast delete-then-re-add sequences
4037    /// that would otherwise race the file-deletion walker.
4038    deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
4039    /// M173 Lane B: in-flight `apply_settings` guard. Concurrent
4040    /// `setPreferences` calls hit `ApplyError::ConcurrentReconfig`
4041    /// rather than interleaving rate-limiter / listener-rebind /
4042    /// DHT-restart phases. Wired by B11.
4043    #[allow(dead_code)] // wired by B11 — concurrent setPreferences guard
4044    reconfig_in_flight: crate::apply::ReconfigInFlight,
4045    self_alert_rx: broadcast::Receiver<Alert>,
4046    resume_save_notify: Arc<tokio::sync::Notify>,
4047    /// M226 Step 5: live settings broadcast to the engine-side OS
4048    /// notification dispatcher spawned in `start_full`. Updated from
4049    /// `handle_apply_settings` so `notify_on_complete` /
4050    /// `notify_on_error` toggles take effect on the very next alert
4051    /// without restarting the dispatcher task.
4052    notification_settings_tx: tokio::sync::watch::Sender<Settings>,
4053    /// M226 Step 5: held alive only for its `Drop` — when the
4054    /// `SessionActor` ends and this field drops, the matching
4055    /// `oneshot::Receiver` in the dispatcher resolves and the
4056    /// dispatcher loop exits cleanly. Belt-and-suspenders with the
4057    /// `alert_tx` broadcast closure (the dispatcher also exits on
4058    /// `RecvError::Closed`), since `alert_tx` is held by every
4059    /// outstanding `SessionHandle::subscribe()` consumer and may
4060    /// outlive the actor.
4061    #[allow(dead_code)]
4062    notification_shutdown_tx: oneshot::Sender<()>,
4063    /// M226 Step 6: triggers the watched-folder dispatcher to drop
4064    /// its current debouncer (releasing inotify FDs) and rebuild
4065    /// against the new path. Pinged by `handle_apply_settings` when
4066    /// the `SettingsDelta` carries a change on `watched_folder` or
4067    /// `delete_torrent_after_add`. Separate from the Settings watch
4068    /// channel because rate-limit / DHT / mask changes shouldn't
4069    /// cause inotify churn.
4070    watched_folder_changed: Arc<tokio::sync::Notify>,
4071    /// M226 Step 6: same Drop-as-shutdown pattern as
4072    /// [`Self::notification_shutdown_tx`].
4073    #[allow(dead_code)]
4074    watched_folder_shutdown_tx: oneshot::Sender<()>,
4075}
4076
4077impl SessionActor {
4078    /// v0.173.1: shared helper for reader sites that used to read
4079    /// `TorrentEntry.meta` (a stale cache that was silently `None` forever
4080    /// for magnet-added torrents). Always queries the `TorrentActor` — the
4081    /// single source of truth for torrent metadata post-v0.173.1.
4082    ///
4083    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` isn't in the
4084    /// registry, [`crate::Error::MetadataNotReady`] if the actor has not yet
4085    /// assembled metadata (magnet pre-resolution), or propagates
4086    /// [`crate::Error::Shutdown`] if the actor has already exited.
4087    async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4088        let entry = self
4089            .torrents
4090            .get(&info_hash)
4091            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4092        entry
4093            .handle
4094            .get_meta()
4095            .await?
4096            .ok_or(crate::Error::MetadataNotReady(info_hash))
4097    }
4098
4099    async fn run(mut self) {
4100        let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4101        refill_interval.tick().await; // skip first immediate tick
4102
4103        let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4104        let mut auto_manage_interval =
4105            tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4106        auto_manage_interval.tick().await; // skip first immediate tick
4107
4108        // Periodic session stats timer (M50)
4109        let stats_interval_ms = self.settings.stats_report_interval;
4110        let mut stats_timer = if stats_interval_ms > 0 {
4111            Some(tokio::time::interval(std::time::Duration::from_millis(
4112                stats_interval_ms,
4113            )))
4114        } else {
4115            None
4116        };
4117        if let Some(ref mut t) = stats_timer {
4118            t.tick().await; // skip first immediate tick
4119        }
4120
4121        // Periodic sample_infohashes timer (BEP 51, M111)
4122        let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4123        let mut sample_timer = if sample_interval_secs > 0 {
4124            Some(tokio::time::interval(std::time::Duration::from_secs(
4125                sample_interval_secs,
4126            )))
4127        } else {
4128            None
4129        };
4130        if let Some(ref mut t) = sample_timer {
4131            t.tick().await; // skip first immediate tick
4132        }
4133
4134        // Periodic resume file save timer (M161)
4135        let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4136            Some(tokio::time::interval(std::time::Duration::from_secs(
4137                self.settings.save_resume_interval_secs,
4138            )))
4139        } else {
4140            None
4141        };
4142        if let Some(ref mut t) = resume_save_interval {
4143            t.tick().await; // skip first immediate tick
4144        }
4145
4146        // Auto-restore torrents from resume files on startup (M161 Phase 5).
4147        {
4148            let resume_dir = self.effective_resume_dir();
4149            let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4150            if !resume_files.is_empty() {
4151                // Reuse the existing sequential restore logic from Phase 4.
4152                match self.handle_load_resume_state().await {
4153                    Ok(result) => {
4154                        info!(
4155                            restored = result.restored,
4156                            skipped = result.skipped,
4157                            failed = result.failed,
4158                            "auto-restored torrents on startup"
4159                        );
4160                    }
4161                    Err(e) => {
4162                        warn!("auto-restore on startup failed: {e}");
4163                    }
4164                }
4165
4166                // Init throttle: restored torrents were queued inside
4167                // handle_load_resume_state when queueing_enabled. Run one
4168                // immediate evaluate_queue() so up to active_checking
4169                // torrents enter Checking right away (don't wait 30s).
4170                if self.settings.queueing_enabled {
4171                    self.evaluate_queue().await;
4172                }
4173
4174                // Orphan cleanup: delete .resume files whose hex stem does not
4175                // match any info hash currently in the session.
4176                let active_hashes: std::collections::HashSet<String> = self
4177                    .torrents
4178                    .keys()
4179                    .map(|h| hex::encode(h.as_bytes()))
4180                    .collect();
4181
4182                // Re-scan after load — some files may have been consumed.
4183                let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4184                for path in &current_files {
4185                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4186                        && !active_hashes.contains(stem)
4187                    {
4188                        if let Err(e) = std::fs::remove_file(path) {
4189                            warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4190                        } else {
4191                            debug!(path = %path.display(), "removed orphan resume file");
4192                        }
4193                    }
4194                }
4195            }
4196        }
4197
4198        loop {
4199            tokio::select! {
4200                cmd = self.cmd_rx.recv() => {
4201                    // M221.1a — per-command timing. `sent_at` is the
4202                    // instant the sender enqueued the command (via
4203                    // `SessionCmdSender::send`); `recv_at` is now.
4204                    // `queue_wait_ms` measures actor backlog; the
4205                    // post-match `handler_ms` covers the dispatch.
4206                    // Shutdown/None paths return early and skip the
4207                    // tracing emit — telemetry is intentionally absent
4208                    // for the terminal command.
4209                    let recv_at = tokio::time::Instant::now();
4210                    let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4211                        recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4212                    });
4213                    let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4214                    let handler_start = tokio::time::Instant::now();
4215                    let cmd = cmd.map(|(_sent_at, c)| c);
4216                    match cmd {
4217                        Some(SessionCommand::AddTorrent {
4218                            meta,
4219                            storage,
4220                            download_dir,
4221                            reply,
4222                        }) => {
4223                            // M223 — spawn-per-add path. The heavy
4224                            // `disk_manager.register_torrent` +
4225                            // `TorrentHandle::from_torrent` work runs in a
4226                            // `tokio::spawn`'d task; results route back via
4227                            // `SessionCommand::CommitAddTorrent`. Legacy entry
4228                            // point: no tags available from the pre-M171
4229                            // command shape.
4230                            let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4231                                let info_hash = meta.as_v1().map_or_else(
4232                                    || meta.info_hashes().best_v1(),
4233                                    |v| v.info_hash,
4234                                );
4235                                if self.torrents.contains_key(&info_hash) {
4236                                    return Err(crate::Error::DuplicateTorrent(info_hash));
4237                                }
4238                                if self.torrents.len() >= self.settings.max_torrents {
4239                                    return Err(crate::Error::SessionAtCapacity(
4240                                        self.settings.max_torrents,
4241                                    ));
4242                                }
4243                                Ok(self.build_add_torrent_prep_bundle(
4244                                    *meta,
4245                                    storage,
4246                                    download_dir,
4247                                    Vec::new(),
4248                                    None,
4249                                ))
4250                            })();
4251                            match setup {
4252                                Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4253                                Err(e) => {
4254                                    let _ = reply.send(Err(e));
4255                                }
4256                            }
4257                        }
4258                        Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4259                            // M223 — feedback variant: the spawn-per-add prep
4260                            // phase finished. `commit_add_torrent` does the
4261                            // mutating fixup on the actor (insert into
4262                            // `self.torrents`, queue position, alert, LSD,
4263                            // M170 post-hooks) using the precomputed
4264                            // `is_private` from the bundle.
4265                            let id = self.commit_add_torrent(result).await;
4266                            let _ = reply.send(id);
4267                        }
4268                        Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4269                            // Legacy entry point: no tags available from the
4270                            // pre-M171 command shape.
4271                            let result = self
4272                                .handle_add_magnet(magnet, download_dir, Vec::new())
4273                                .await;
4274                            let _ = reply.send(result);
4275                        }
4276                        Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4277                            let result = self.handle_remove_torrent(info_hash).await;
4278                            let _ = reply.send(result);
4279                        }
4280                        Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4281                            let result = self.handle_pause_torrent(info_hash).await;
4282                            let _ = reply.send(result);
4283                        }
4284                        Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4285                            let result = self.handle_resume_torrent(info_hash).await;
4286                            let _ = reply.send(result);
4287                        }
4288                        Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4289                            let result = self.handle_force_resume_torrent(info_hash).await;
4290                            let _ = reply.send(result);
4291                        }
4292                        Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4293                            let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4294                            let _ = reply.send(result);
4295                        }
4296                        Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4297                            let result = self.handle_torrent_stats(info_hash).await;
4298                            let _ = reply.send(result);
4299                        }
4300                        Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4301                            // v0.173.1: handle_torrent_info is now async because
4302                            // it queries the TorrentActor for metadata (Class A
4303                            // architectural fix — no more TorrentEntry.meta cache).
4304                            let result = self.handle_torrent_info(info_hash).await;
4305                            let _ = reply.send(result);
4306                        }
4307                        Some(SessionCommand::ListTorrents { reply }) => {
4308                            let list: Vec<Id20> = self.torrents.keys().copied().collect();
4309                            let _ = reply.send(list);
4310                        }
4311                        Some(SessionCommand::SessionStats { reply }) => {
4312                            let stats = self.make_session_stats().await;
4313                            let _ = reply.send(stats);
4314                        }
4315                        Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4316                            let result = self.handle_save_torrent_resume(info_hash).await;
4317                            let _ = reply.send(result);
4318                        }
4319                        Some(SessionCommand::SaveSessionState { reply }) => {
4320                            let result = self.handle_save_session_state().await;
4321                            let _ = reply.send(result);
4322                        }
4323                        Some(SessionCommand::LoadResumeState { reply }) => {
4324                            let result = self.handle_load_resume_state().await;
4325                            let _ = reply.send(result);
4326                        }
4327                        Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4328                            let result = match self.torrents.get(&info_hash) {
4329                                Some(entry) => Ok(entry.queue_position),
4330                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4331                            };
4332                            let _ = reply.send(result);
4333                        }
4334                        Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4335                            let result = self.handle_set_queue_position(info_hash, pos);
4336                            let _ = reply.send(result);
4337                        }
4338                        Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4339                            let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4340                            let _ = reply.send(result);
4341                        }
4342                        Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4343                            let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4344                            let _ = reply.send(result);
4345                        }
4346                        Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4347                            let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4348                            let _ = reply.send(result);
4349                        }
4350                        Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4351                            let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4352                            let _ = reply.send(result);
4353                        }
4354                        Some(SessionCommand::BanPeer { ip, reply }) => {
4355                            self.ban_manager.write().ban(ip);
4356                            let _ = reply.send(());
4357                        }
4358                        Some(SessionCommand::UnbanPeer { ip, reply }) => {
4359                            let was_banned = self.ban_manager.write().unban(&ip);
4360                            let _ = reply.send(was_banned);
4361                        }
4362                        Some(SessionCommand::BannedPeers { reply }) => {
4363                            let list: Vec<IpAddr> = self.ban_manager.read()
4364                                .banned_list().iter().copied().collect();
4365                            let _ = reply.send(list);
4366                        }
4367                        Some(SessionCommand::SetIpFilter { filter, reply }) => {
4368                            *self.ip_filter.write() = filter;
4369                            let _ = reply.send(());
4370                        }
4371                        Some(SessionCommand::GetIpFilter { reply }) => {
4372                            let filter = self.ip_filter.read().clone();
4373                            let _ = reply.send(filter);
4374                        }
4375                        Some(SessionCommand::GetSettings { reply }) => {
4376                            let _ = reply.send(self.settings.clone());
4377                        }
4378                        Some(SessionCommand::ApplySettings { settings, reply }) => {
4379                            let result = self.handle_apply_settings(*settings);
4380                            let _ = reply.send(result);
4381                        }
4382                        Some(SessionCommand::DhtNodeCount { reply }) => {
4383                            // Sum routing-table sizes across both DHT
4384                            // instances. Either instance erroring or being
4385                            // absent contributes 0 — the qBt wire field is
4386                            // a best-effort gauge, not a strict invariant.
4387                            let mut total: usize = 0;
4388                            if let Some(dht) = &self.dht_v4
4389                                && let Ok(c) = dht.node_count().await
4390                            {
4391                                total += c;
4392                            }
4393                            if let Some(dht) = &self.dht_v6
4394                                && let Ok(c) = dht.node_count().await
4395                            {
4396                                total += c;
4397                            }
4398                            let _ = reply.send(total);
4399                        }
4400                        Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4401                            let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4402                            let _ = reply.send(result);
4403                        }
4404                        Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4405                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4406                                entry.handle.add_peers(peers, source).await
4407                            } else {
4408                                Err(crate::Error::TorrentNotFound(info_hash))
4409                            };
4410                            let _ = reply.send(result);
4411                        }
4412                        Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4413                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4414                                entry.handle.open_file(file_index).await
4415                            } else {
4416                                Err(crate::Error::TorrentNotFound(info_hash))
4417                            };
4418                            let _ = reply.send(result);
4419                        }
4420                        Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4421                            let result = match self.torrents.get(&info_hash) {
4422                                Some(entry) => {
4423                                    entry.handle.force_reannounce().await
4424                                }
4425                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4426                            };
4427                            let _ = reply.send(result);
4428                        }
4429                        Some(SessionCommand::TrackerList { info_hash, reply }) => {
4430                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4431                                entry.handle.tracker_list().await
4432                            } else {
4433                                Err(crate::Error::TorrentNotFound(info_hash))
4434                            };
4435                            let _ = reply.send(result);
4436                        }
4437                        Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4438                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4439                                entry.handle.peer_source_counts().await
4440                            } else {
4441                                Err(crate::Error::TorrentNotFound(info_hash))
4442                            };
4443                            let _ = reply.send(result);
4444                        }
4445                        Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4446                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4447                                entry.handle.query_unchoke_durations().await.ok()
4448                            } else {
4449                                None
4450                            };
4451                            let _ = reply.send(result);
4452                        }
4453                        Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4454                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4455                                entry.handle.get_web_seed_stats().await
4456                            } else {
4457                                Err(crate::Error::TorrentNotFound(info_hash))
4458                            };
4459                            let _ = reply.send(result);
4460                        }
4461                        Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4462                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4463                                entry.handle.get_web_seeds().await
4464                            } else {
4465                                Err(crate::Error::TorrentNotFound(info_hash))
4466                            };
4467                            let _ = reply.send(result);
4468                        }
4469                        Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4470                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4471                                entry.handle.get_piece_states().await
4472                            } else {
4473                                Err(crate::Error::TorrentNotFound(info_hash))
4474                            };
4475                            let _ = reply.send(result);
4476                        }
4477                        Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4478                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4479                                entry.handle.get_piece_hashes(offset, limit).await
4480                            } else {
4481                                Err(crate::Error::TorrentNotFound(info_hash))
4482                            };
4483                            let _ = reply.send(result);
4484                        }
4485                        Some(SessionCommand::Scrape { info_hash, reply }) => {
4486                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4487                                entry.handle.scrape().await
4488                            } else {
4489                                Err(crate::Error::TorrentNotFound(info_hash))
4490                            };
4491                            let _ = reply.send(result);
4492                        }
4493                        Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4494                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4495                                entry.handle.set_file_priority(index, priority).await
4496                            } else {
4497                                Err(crate::Error::TorrentNotFound(info_hash))
4498                            };
4499                            let _ = reply.send(result);
4500                        }
4501                        Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4502                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4503                                entry.handle.file_priorities().await
4504                            } else {
4505                                Err(crate::Error::TorrentNotFound(info_hash))
4506                            };
4507                            let _ = reply.send(result);
4508                        }
4509                        Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4510                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4511                                entry.handle.set_download_limit(bytes_per_sec).await
4512                            } else {
4513                                Err(crate::Error::TorrentNotFound(info_hash))
4514                            };
4515                            let _ = reply.send(result);
4516                        }
4517                        Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4518                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4519                                entry.handle.set_upload_limit(bytes_per_sec).await
4520                            } else {
4521                                Err(crate::Error::TorrentNotFound(info_hash))
4522                            };
4523                            let _ = reply.send(result);
4524                        }
4525                        Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4526                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4527                                entry.handle.download_limit().await
4528                            } else {
4529                                Err(crate::Error::TorrentNotFound(info_hash))
4530                            };
4531                            let _ = reply.send(result);
4532                        }
4533                        Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4534                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4535                                entry.handle.upload_limit().await
4536                            } else {
4537                                Err(crate::Error::TorrentNotFound(info_hash))
4538                            };
4539                            let _ = reply.send(result);
4540                        }
4541                        Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4542                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4543                                entry.handle.set_sequential_download(enabled).await
4544                            } else {
4545                                Err(crate::Error::TorrentNotFound(info_hash))
4546                            };
4547                            let _ = reply.send(result);
4548                        }
4549                        Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4550                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4551                                entry.handle.is_sequential_download().await
4552                            } else {
4553                                Err(crate::Error::TorrentNotFound(info_hash))
4554                            };
4555                            let _ = reply.send(result);
4556                        }
4557                        Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4558                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4559                                entry.handle.set_super_seeding(enabled).await
4560                            } else {
4561                                Err(crate::Error::TorrentNotFound(info_hash))
4562                            };
4563                            let _ = reply.send(result);
4564                        }
4565                        Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4566                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4567                                entry.handle.is_super_seeding().await
4568                            } else {
4569                                Err(crate::Error::TorrentNotFound(info_hash))
4570                            };
4571                            let _ = reply.send(result);
4572                        }
4573                        Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4574                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4575                                entry.handle.set_seed_mode(enabled).await
4576                            } else {
4577                                Err(crate::Error::TorrentNotFound(info_hash))
4578                            };
4579                            let _ = reply.send(result);
4580                        }
4581                        Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4582                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4583                                entry.handle.add_tracker(url).await
4584                            } else {
4585                                Err(crate::Error::TorrentNotFound(info_hash))
4586                            };
4587                            let _ = reply.send(result);
4588                        }
4589                        Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4590                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4591                                entry.handle.replace_trackers(urls).await
4592                            } else {
4593                                Err(crate::Error::TorrentNotFound(info_hash))
4594                            };
4595                            let _ = reply.send(result);
4596                        }
4597                        Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4598                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4599                                entry.handle.force_recheck().await
4600                            } else {
4601                                Err(crate::Error::TorrentNotFound(info_hash))
4602                            };
4603                            let _ = reply.send(result);
4604                        }
4605                        Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4606                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4607                                entry.handle.rename_file(file_index, new_name).await
4608                            } else {
4609                                Err(crate::Error::TorrentNotFound(info_hash))
4610                            };
4611                            let _ = reply.send(result);
4612                        }
4613                        Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4614                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4615                                entry.handle.set_max_connections(limit).await
4616                            } else {
4617                                Err(crate::Error::TorrentNotFound(info_hash))
4618                            };
4619                            let _ = reply.send(result);
4620                        }
4621                        Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4622                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4623                                entry.handle.max_connections().await
4624                            } else {
4625                                Err(crate::Error::TorrentNotFound(info_hash))
4626                            };
4627                            let _ = reply.send(result);
4628                        }
4629                        Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4630                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4631                                entry.handle.set_max_uploads(limit).await
4632                            } else {
4633                                Err(crate::Error::TorrentNotFound(info_hash))
4634                            };
4635                            let _ = reply.send(result);
4636                        }
4637                        Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4638                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4639                                entry.handle.max_uploads().await
4640                            } else {
4641                                Err(crate::Error::TorrentNotFound(info_hash))
4642                            };
4643                            let _ = reply.send(result);
4644                        }
4645                        Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4646                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4647                                entry.handle.get_peer_info().await
4648                            } else {
4649                                Err(crate::Error::TorrentNotFound(info_hash))
4650                            };
4651                            let _ = reply.send(result);
4652                        }
4653                        Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4654                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4655                                entry.handle.get_download_queue().await
4656                            } else {
4657                                Err(crate::Error::TorrentNotFound(info_hash))
4658                            };
4659                            let _ = reply.send(result);
4660                        }
4661                        Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4662                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4663                                entry.handle.have_piece(index).await
4664                            } else {
4665                                Err(crate::Error::TorrentNotFound(info_hash))
4666                            };
4667                            let _ = reply.send(result);
4668                        }
4669                        Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4670                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4671                                entry.handle.piece_availability().await
4672                            } else {
4673                                Err(crate::Error::TorrentNotFound(info_hash))
4674                            };
4675                            let _ = reply.send(result);
4676                        }
4677                        Some(SessionCommand::FileProgress { info_hash, reply }) => {
4678                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4679                                entry.handle.file_progress().await
4680                            } else {
4681                                Err(crate::Error::TorrentNotFound(info_hash))
4682                            };
4683                            let _ = reply.send(result);
4684                        }
4685                        Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4686                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4687                                entry.handle.info_hashes().await
4688                            } else {
4689                                Err(crate::Error::TorrentNotFound(info_hash))
4690                            };
4691                            let _ = reply.send(result);
4692                        }
4693                        Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4694                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4695                                entry.handle.torrent_file().await
4696                            } else {
4697                                Err(crate::Error::TorrentNotFound(info_hash))
4698                            };
4699                            let _ = reply.send(result);
4700                        }
4701                        Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4702                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4703                                entry.handle.torrent_file_v2().await
4704                            } else {
4705                                Err(crate::Error::TorrentNotFound(info_hash))
4706                            };
4707                            let _ = reply.send(result);
4708                        }
4709                        #[cfg(feature = "test-util")]
4710                        Some(SessionCommand::TestInjectMetadata {
4711                            info_hash,
4712                            info_bytes,
4713                            reply,
4714                        }) => {
4715                            let result = match self.torrents.get(&info_hash) {
4716                                Some(entry) => {
4717                                    entry.handle.test_inject_metadata(info_bytes).await
4718                                }
4719                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4720                            };
4721                            let _ = reply.send(result);
4722                        }
4723                        Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4724                            // v0.173.2: BEP 27 enforcement on the DHT path. M173.1 fixed the
4725                            // LSD leak vector but missed DHT — private torrents would still
4726                            // announce their info hash to DHT and leak peer IPs. Mirrors the
4727                            // LSD pattern at session.rs:3541-3563.
4728                            let result = match self.torrents.get(&info_hash) {
4729                                Some(entry) => {
4730                                    if entry.is_private().await {
4731                                        Err(crate::Error::InvalidSettings(
4732                                            "DHT disabled for private torrent".into(),
4733                                        ))
4734                                    } else {
4735                                        entry.handle.force_dht_announce().await
4736                                    }
4737                                }
4738                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4739                            };
4740                            let _ = reply.send(result);
4741                        }
4742                        Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4743                            // LSD is session-level: verify the torrent exists, then announce directly.
4744                            //
4745                            // v0.173.1: is_private is now async. Match guards can't be async, so
4746                            // evaluate the flag up front and branch on the bool.
4747                            let result = match self.torrents.get(&info_hash) {
4748                                Some(entry) => {
4749                                    if entry.is_private().await {
4750                                        // BEP 27: private torrents must not use LSD
4751                                        Err(crate::Error::InvalidSettings(
4752                                            "LSD disabled for private torrent".into(),
4753                                        ))
4754                                    } else {
4755                                        if let Some(ref lsd) = self.lsd {
4756                                            lsd.announce(vec![info_hash]).await;
4757                                        }
4758                                        Ok(())
4759                                    }
4760                                }
4761                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4762                            };
4763                            let _ = reply.send(result);
4764                        }
4765                        Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4766                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4767                                entry.handle.read_piece(index).await
4768                            } else {
4769                                Err(crate::Error::TorrentNotFound(info_hash))
4770                            };
4771                            let _ = reply.send(result);
4772                        }
4773                        Some(SessionCommand::FlushCache { info_hash, reply }) => {
4774                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4775                                entry.handle.flush_cache().await
4776                            } else {
4777                                Err(crate::Error::TorrentNotFound(info_hash))
4778                            };
4779                            let _ = reply.send(result);
4780                        }
4781                        Some(SessionCommand::IsValid { info_hash, reply }) => {
4782                            let valid = self.torrents.get(&info_hash)
4783                                .is_some_and(|e| e.handle.is_valid());
4784                            let _ = reply.send(valid);
4785                        }
4786                        Some(SessionCommand::ClearError { info_hash, reply }) => {
4787                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4788                                entry.handle.clear_error().await
4789                            } else {
4790                                Err(crate::Error::TorrentNotFound(info_hash))
4791                            };
4792                            let _ = reply.send(result);
4793                        }
4794                        Some(SessionCommand::FileStatus { info_hash, reply }) => {
4795                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4796                                entry.handle.file_status().await
4797                            } else {
4798                                Err(crate::Error::TorrentNotFound(info_hash))
4799                            };
4800                            let _ = reply.send(result);
4801                        }
4802                        Some(SessionCommand::Flags { info_hash, reply }) => {
4803                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4804                                entry.handle.flags().await
4805                            } else {
4806                                Err(crate::Error::TorrentNotFound(info_hash))
4807                            };
4808                            let _ = reply.send(result);
4809                        }
4810                        Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
4811                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4812                                entry.handle.set_flags(flags).await
4813                            } else {
4814                                Err(crate::Error::TorrentNotFound(info_hash))
4815                            };
4816                            let _ = reply.send(result);
4817                        }
4818                        Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
4819                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4820                                entry.handle.unset_flags(flags).await
4821                            } else {
4822                                Err(crate::Error::TorrentNotFound(info_hash))
4823                            };
4824                            let _ = reply.send(result);
4825                        }
4826                        Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
4827                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4828                                entry.handle.connect_peer(addr).await
4829                            } else {
4830                                Err(crate::Error::TorrentNotFound(info_hash))
4831                            };
4832                            let _ = reply.send(result);
4833                        }
4834                        Some(SessionCommand::DhtPutImmutable { value, reply }) => {
4835                            let result = self.handle_dht_put_immutable(value).await;
4836                            let _ = reply.send(result);
4837                        }
4838                        Some(SessionCommand::DhtGetImmutable { target, reply }) => {
4839                            let result = self.handle_dht_get_immutable(target).await;
4840                            let _ = reply.send(result);
4841                        }
4842                        Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
4843                            let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
4844                            let _ = reply.send(result);
4845                        }
4846                        Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
4847                            let result = self.handle_dht_get_mutable(public_key, salt).await;
4848                            let _ = reply.send(result);
4849                        }
4850                        Some(SessionCommand::PostSessionStats) => {
4851                            self.fire_stats_alert();
4852                        }
4853                        Some(SessionCommand::SaveResumeState { reply }) => {
4854                            let count = self.save_dirty_resume_files().await;
4855                            let _ = reply.send(Ok(count));
4856                        }
4857                        Some(SessionCommand::AddTorrentM170 { params, reply }) => {
4858                            // M223 — bytes branch goes through the
4859                            // spawn-per-add path; magnet branch stays inline.
4860                            // The dispatcher consumes `reply` directly.
4861                            self.dispatch_add_torrent_m170(*params, reply).await;
4862                        }
4863                        Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
4864                            let result = self.handle_create_category(name, save_path).await;
4865                            let _ = reply.send(result);
4866                        }
4867                        Some(SessionCommand::EditCategory { name, save_path, reply }) => {
4868                            let result = self.handle_edit_category(name, save_path).await;
4869                            let _ = reply.send(result);
4870                        }
4871                        Some(SessionCommand::RemoveCategories { names, reply }) => {
4872                            let result = self.handle_remove_categories(names).await;
4873                            let _ = reply.send(result);
4874                        }
4875                        Some(SessionCommand::ListCategories { reply }) => {
4876                            let snapshot = self.category_registry.read().list();
4877                            let _ = reply.send(snapshot);
4878                        }
4879                        Some(SessionCommand::CreateTags { names, reply }) => {
4880                            let results: Vec<_> = {
4881                                let mut reg = self.tag_registry.write();
4882                                names.into_iter().map(|n| reg.create(n)).collect()
4883                            };
4884                            // Persist any successful creates. Persistence failures
4885                            // warn but don't change the per-call reply (matches
4886                            // CreateCategory).
4887                            if let Err(e) = self.persist_tag_registry().await {
4888                                tracing::warn!(
4889                                    error = %e,
4890                                    "failed to persist tag registry after CreateTags"
4891                                );
4892                            }
4893                            let _ = reply.send(results);
4894                        }
4895                        Some(SessionCommand::DeleteTags { names, reply }) => {
4896                            let removed = self.handle_delete_tags(names).await;
4897                            let _ = reply.send(removed);
4898                        }
4899                        Some(SessionCommand::ListTags { reply }) => {
4900                            let names = self.tag_registry.read().list();
4901                            let _ = reply.send(names);
4902                        }
4903                        Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
4904                            let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
4905                            let _ = reply.send(res);
4906                        }
4907                        Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
4908                            let res = self
4909                                .handle_remove_tags_from_torrents(info_hashes, tags)
4910                                .await;
4911                            let _ = reply.send(res);
4912                        }
4913                        Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
4914                            let result = self.handle_remove_torrent_with_files(info_hash).await;
4915                            let _ = reply.send(result);
4916                        }
4917                        Some(SessionCommand::DebugState { reply }) => {
4918                            let state = self.make_debug_state().await;
4919                            let _ = reply.send(state);
4920                        }
4921                        Some(SessionCommand::Shutdown) | None => {
4922                            self.shutdown_all().await;
4923                            return;
4924                        }
4925                    }
4926                    // M221.1a — emit per-command timing on the
4927                    // bench-harness target. Filtering happens in the
4928                    // tracing subscriber (parallel-7 harness sets
4929                    // RUST_LOG=irontide_session::cmd_timing=info); the
4930                    // emit is unconditional so any external consumer
4931                    // can opt in.
4932                    let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
4933                    info!(
4934                        target: "irontide_session::cmd_timing",
4935                        cmd = cmd_name,
4936                        queue_wait_ms = queue_wait_ms,
4937                        handler_ms = handler_ms,
4938                        "session_cmd"
4939                    );
4940                }
4941                result = async {
4942                    match &mut self.lsd_peers_rx {
4943                        Some(rx) => rx.recv().await,
4944                        None => std::future::pending().await,
4945                    }
4946                } => {
4947                    if let Some((info_hash, peer_addr)) = result
4948                        && let Some(entry) = self.torrents.get(&info_hash)
4949                    {
4950                        // v0.173.1: is_private is async — can't chain it into the let-else
4951                        // condition list, evaluate separately before add_peers.
4952                        let is_priv = entry.is_private().await;
4953                        if !is_priv {
4954                            // BEP 27: reject LSD peers for private torrents
4955                            let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
4956                        }
4957                    }
4958                }
4959                // Pre-validated inbound connections from ListenerTask (M114)
4960                Some(conn) = self.validated_conn_rx.recv() => {
4961                    self.handle_identified_inbound(conn);
4962                }
4963                // SSL inbound connections (M42)
4964                result = async {
4965                    if let Some(ref mut listener) = self.ssl_listener {
4966                        listener.accept().await
4967                    } else {
4968                        std::future::pending().await
4969                    }
4970                } => {
4971                    if let Ok((stream, addr)) = result {
4972                        self.handle_ssl_incoming(stream, addr).await;
4973                    }
4974                }
4975                // Global rate limiter refill (100ms)
4976                _ = refill_interval.tick() => {
4977                    let elapsed = std::time::Duration::from_millis(100);
4978                    self.global_upload_bucket.lock().refill(elapsed);
4979                    self.global_download_bucket.lock().refill(elapsed);
4980                }
4981                // Auto-manage queue evaluation
4982                _ = auto_manage_interval.tick() => {
4983                    self.evaluate_queue().await;
4984                }
4985                // Checking-complete trigger: when a torrent exits Checking
4986                // state, re-evaluate immediately so the next candidate
4987                // promotes without waiting for the 30s periodic tick.
4988                alert = self.self_alert_rx.recv() => {
4989                    if let Ok(alert) = alert
4990                        && matches!(
4991                            alert.kind,
4992                            AlertKind::StateChanged {
4993                                prev_state: TorrentState::Checking,
4994                                new_state,
4995                                ..
4996                            } if new_state != TorrentState::Checking
4997                        )
4998                    {
4999                        self.evaluate_queue().await;
5000                    }
5001                }
5002                // NAT port mapping events
5003                event = recv_nat_event(&mut self.nat_events_rx) => {
5004                    match event {
5005                        irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
5006                            info!(port, %protocol, "port mapping succeeded");
5007                            post_alert(
5008                                &self.alert_tx,
5009                                &self.alert_mask,
5010                                AlertKind::PortMappingSucceeded { port, protocol },
5011                            );
5012                        }
5013                        irontide_nat::NatEvent::MappingFailed { port, message } => {
5014                            warn!(port, %message, "port mapping failed");
5015                            post_alert(
5016                                &self.alert_tx,
5017                                &self.alert_mask,
5018                                AlertKind::PortMappingFailed { port, message },
5019                            );
5020                        }
5021                        irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
5022                            info!(%ip, "external IP discovered via NAT traversal");
5023                            self.external_ip = Some(ip);
5024                            // Propagate to all active torrents for BEP 40 peer priority.
5025                            for entry in self.torrents.values() {
5026                                let _ = entry.handle.update_external_ip(ip).await;
5027                            }
5028                            // BEP 42: notify DHT instances of external IP
5029                            if let Some(dht) = &self.dht_v4 {
5030                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5031                            }
5032                            if let Some(dht) = &self.dht_v6 {
5033                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5034                            }
5035                        }
5036                    }
5037                }
5038                // BEP 42: DHT v4 external IP consensus
5039                Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
5040                    info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
5041                    self.external_ip = Some(ip);
5042                    for entry in self.torrents.values() {
5043                        let _ = entry.handle.update_external_ip(ip).await;
5044                    }
5045                }
5046                // BEP 42: DHT v6 external IP consensus
5047                Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5048                    info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5049                    self.external_ip = Some(ip);
5050                    for entry in self.torrents.values() {
5051                        let _ = entry.handle.update_external_ip(ip).await;
5052                    }
5053                }
5054                // Periodic session stats (M50)
5055                _ = async {
5056                    match &mut stats_timer {
5057                        Some(t) => t.tick().await,
5058                        None => std::future::pending().await,
5059                    }
5060                } => {
5061                    self.fire_stats_alert();
5062                }
5063                // Periodic sample_infohashes (BEP 51, M111)
5064                _ = async {
5065                    match &mut sample_timer {
5066                        Some(t) => t.tick().await,
5067                        None => std::future::pending().await,
5068                    }
5069                } => {
5070                    self.fire_sample_infohashes().await;
5071                }
5072                // Periodic resume file save (M161)
5073                _ = async {
5074                    match &mut resume_save_interval {
5075                        Some(t) => t.tick().await,
5076                        None => std::future::pending().await,
5077                    }
5078                } => {
5079                    let count = self.save_dirty_resume_files().await;
5080                    if count > 0 {
5081                        info!(count, "periodic resume save completed");
5082                    }
5083                }
5084                // Periodic resume save interval rebuild (M225)
5085                () = self.resume_save_notify.notified() => {
5086                    resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5087                        Some(tokio::time::interval(std::time::Duration::from_secs(
5088                            self.settings.save_resume_interval_secs,
5089                        )))
5090                    } else {
5091                        None
5092                    };
5093                    if let Some(ref mut t) = resume_save_interval {
5094                        t.tick().await; // skip first immediate tick
5095                    }
5096                }
5097            }
5098        }
5099    }
5100
5101    /// Return clones of global buckets if they have a non-zero rate, else None.
5102    fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5103        let up = if self.settings.upload_rate_limit > 0 {
5104            Some(Arc::clone(&self.global_upload_bucket))
5105        } else {
5106            None
5107        };
5108        let down = if self.settings.download_rate_limit > 0 {
5109            Some(Arc::clone(&self.global_download_bucket))
5110        } else {
5111            None
5112        };
5113        (up, down)
5114    }
5115
5116    fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5117        if self.settings.auto_upload_slots {
5118            crate::slot_tuner::SlotTuner::new(
5119                4, // initial slots
5120                self.settings.auto_upload_slots_min,
5121                self.settings.auto_upload_slots_max,
5122            )
5123        } else {
5124            crate::slot_tuner::SlotTuner::disabled(4)
5125        }
5126    }
5127
5128    fn make_torrent_config(&self) -> TorrentConfig {
5129        TorrentConfig::from(&self.settings)
5130    }
5131
5132    /// Returns the next available queue position (one past the max).
5133    fn next_queue_position(&self) -> i32 {
5134        self.torrents
5135            .values()
5136            .filter(|e| e.auto_managed)
5137            .map(|e| e.queue_position)
5138            .max()
5139            .map_or(0, |m| m + 1)
5140    }
5141
5142    /// Inline add-torrent path: builds the prep bundle on the actor,
5143    /// runs prepare + commit phases sequentially without spawning. Used
5144    /// by the resume-restore startup path (single-threaded by
5145    /// construction) and the `handle_add_torrent_with_params` M170
5146    /// helper. The `AddTorrent` and `AddTorrentM170` recv arms call
5147    /// `try_spawn_add_torrent` instead — same prep + commit primitives,
5148    /// but the prep runs in a `tokio::spawn` and the commit hop returns
5149    /// via `SessionCommand::CommitAddTorrent` (M223).
5150    async fn handle_add_torrent(
5151        &mut self,
5152        torrent_meta: irontide_core::TorrentMeta,
5153        storage: Option<Arc<dyn TorrentStorage>>,
5154        download_dir: Option<PathBuf>,
5155        tags: Vec<String>,
5156    ) -> crate::Result<Id20> {
5157        let info_hash = torrent_meta
5158            .as_v1()
5159            .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5160        if self.torrents.contains_key(&info_hash) {
5161            return Err(crate::Error::DuplicateTorrent(info_hash));
5162        }
5163        if self.torrents.len() >= self.settings.max_torrents {
5164            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5165        }
5166        let bundle =
5167            self.build_add_torrent_prep_bundle(torrent_meta, storage, download_dir, tags, None);
5168        let prep = prepare_add_torrent_off_actor(bundle).await;
5169        self.commit_add_torrent(prep).await
5170    }
5171
5172    /// M223 — build the off-actor add-torrent prep bundle synchronously
5173    /// from session state. Cheap Arc-clones + a single
5174    /// `make_torrent_config` snapshot; never awaits, so it stays on the
5175    /// actor without contributing to the queue-wait stack.
5176    fn build_add_torrent_prep_bundle(
5177        &self,
5178        torrent_meta: irontide_core::TorrentMeta,
5179        storage: Option<Arc<dyn TorrentStorage>>,
5180        download_dir: Option<PathBuf>,
5181        tags: Vec<String>,
5182        m170_post: Option<M170PostAdd>,
5183    ) -> AddTorrentPrepBundle {
5184        let mut torrent_config = self.make_torrent_config();
5185        if let Some(dir) = download_dir {
5186            torrent_config.download_dir = dir;
5187        }
5188        // M171: bake tags into the config BEFORE the actor is constructed so
5189        // the first `stats()` snapshot already carries them — no post-add
5190        // spawn race. An empty vec is a no-op but still replaces any
5191        // session-level default (currently also empty).
5192        torrent_config.tags = tags;
5193
5194        let (global_up, global_down) = self.global_buckets_if_limited();
5195        let slot_tuner = self.make_slot_tuner();
5196
5197        AddTorrentPrepBundle {
5198            torrent_meta,
5199            storage_override: storage,
5200            torrent_config,
5201            disk_manager: self.disk_manager.clone(),
5202            dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5203            dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5204            global_up,
5205            global_down,
5206            slot_tuner,
5207            alert_tx: self.alert_tx.clone(),
5208            alert_mask: Arc::clone(&self.alert_mask),
5209            utp_socket: self.utp_socket.clone(),
5210            utp_socket_v6: self.utp_socket_v6.clone(),
5211            ban_manager: Arc::clone(&self.ban_manager),
5212            ip_filter: Arc::clone(&self.ip_filter),
5213            plugins: Arc::clone(&self.plugins),
5214            sam_session: self.sam_session.clone(),
5215            ssl_manager: self.ssl_manager.clone(),
5216            factory: Arc::clone(&self.factory),
5217            hash_pool: Arc::clone(&self.hash_pool),
5218            counters: Arc::clone(&self.counters),
5219            m170_post,
5220        }
5221    }
5222
5223    /// M223 — commit phase of the spawn-per-add fix. Mutates session
5224    /// state (insert into `self.torrents`, info-hash registry, queue
5225    /// position, alert, LSD announce, M170 post-hooks) using the
5226    /// success-path payload from `prepare_add_torrent_off_actor`. On
5227    /// `Err` from prep, returns the error untouched.
5228    async fn commit_add_torrent(
5229        &mut self,
5230        prep: crate::Result<PreparedAddTorrent>,
5231    ) -> crate::Result<Id20> {
5232        let PreparedAddTorrent {
5233            handle,
5234            info_hash,
5235            is_private,
5236            m170_post,
5237        } = prep?;
5238        // Re-check dup + capacity: parallel adds may both pass the
5239        // pre-spawn check, then race the commit. The later one fails
5240        // here, and dropping `handle` triggers the spawned TorrentActor's
5241        // graceful shutdown (its cmd_tx receiver hangs up → recv returns
5242        // None → actor exits).
5243        if self.torrents.contains_key(&info_hash) {
5244            drop(handle);
5245            return Err(crate::Error::DuplicateTorrent(info_hash));
5246        }
5247        if self.torrents.len() >= self.settings.max_torrents {
5248            drop(handle);
5249            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5250        }
5251        self.torrents.insert(
5252            info_hash,
5253            TorrentEntry {
5254                handle,
5255                queue_position: -1,
5256                auto_managed: true,
5257                started_at: Some(tokio::time::Instant::now()),
5258                smoothed_download_rate: f64::MAX,
5259                smoothed_upload_rate: f64::MAX,
5260            },
5261        );
5262        self.info_hash_registry.insert(info_hash, ());
5263
5264        // Assign queue position for auto-managed torrents
5265        let pos = self.next_queue_position();
5266        if let Some(entry) = self.torrents.get_mut(&info_hash)
5267            && entry.auto_managed
5268        {
5269            entry.queue_position = pos;
5270        }
5271
5272        info!(%info_hash, "torrent added to session");
5273        // M223 — `TorrentAdded` is posted in `prepare_add_torrent_off_actor`
5274        // (BEFORE the `commit_tx.send.await` yield) to preserve the alert
5275        // ordering invariant under spawn-per-add. See the comment there.
5276        // BEP 27: private torrents must not use LSD. `is_private` is
5277        // precomputed from `meta.info.private == Some(1)` in the prep
5278        // bundle, so the commit arm needs no async query.
5279        if let Some(ref lsd) = self.lsd
5280            && !is_private
5281        {
5282            lsd.announce(vec![info_hash]).await;
5283        }
5284        // M170 post-add hooks (category + paused-on-add) for the
5285        // `AddTorrentM170` path. Always `None` for the legacy
5286        // `AddTorrent` path.
5287        if let Some(M170PostAdd { category, paused }) = m170_post {
5288            self.apply_post_add_m170(info_hash, category, paused);
5289        }
5290        Ok(info_hash)
5291    }
5292
5293    /// M223 — recv-arm helper: spawn the prep phase off the actor,
5294    /// route the result back via `CommitAddTorrent`. Consumes `reply`,
5295    /// so callers must not also send to it.
5296    fn try_spawn_add_torrent(
5297        &self,
5298        bundle: AddTorrentPrepBundle,
5299        reply: oneshot::Sender<crate::Result<Id20>>,
5300    ) {
5301        let commit_tx = self.commit_tx.clone();
5302        tokio::spawn(async move {
5303            let result = prepare_add_torrent_off_actor(bundle).await;
5304            if commit_tx
5305                .send(SessionCommand::CommitAddTorrent { result, reply })
5306                .await
5307                .is_err()
5308            {
5309                // Session is shutting down; the receiver is gone too.
5310                // The `reply` oneshot was moved into the variant which
5311                // got dropped, so the original caller observes a closed
5312                // channel — the right shutdown signal.
5313                warn!("M223 prep task: commit_tx send failed (session shutting down)");
5314            }
5315        });
5316    }
5317
5318    async fn handle_add_magnet(
5319        &mut self,
5320        magnet: Magnet,
5321        download_dir: Option<PathBuf>,
5322        tags: Vec<String>,
5323    ) -> crate::Result<Id20> {
5324        let info_hash = magnet.info_hash();
5325        let display_name = magnet.display_name.clone().unwrap_or_default();
5326        if self.torrents.contains_key(&info_hash) {
5327            return Err(crate::Error::DuplicateTorrent(info_hash));
5328        }
5329        if self.torrents.len() >= self.settings.max_torrents {
5330            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5331        }
5332        let mut config = self.make_torrent_config();
5333        if let Some(dir) = download_dir {
5334            config.download_dir = dir;
5335        }
5336        // M171: bake tags into the config BEFORE the actor is constructed so
5337        // the first `stats()` snapshot already carries them — no post-add
5338        // spawn race. An empty vec is a no-op but still replaces any
5339        // session-level default (currently also empty).
5340        config.tags = tags;
5341        let (global_up, global_down) = self.global_buckets_if_limited();
5342        let slot_tuner = self.make_slot_tuner();
5343        let handle = TorrentHandle::from_magnet(
5344            magnet,
5345            self.disk_manager.clone(),
5346            config,
5347            self.dht_v4_broadcast.subscribe(),
5348            self.dht_v6_broadcast.subscribe(),
5349            global_up,
5350            global_down,
5351            slot_tuner,
5352            self.alert_tx.clone(),
5353            Arc::clone(&self.alert_mask),
5354            self.utp_socket.clone(),
5355            self.utp_socket_v6.clone(),
5356            Arc::clone(&self.ban_manager),
5357            Arc::clone(&self.ip_filter),
5358            Arc::clone(&self.plugins),
5359            self.sam_session.clone(),
5360            self.ssl_manager.clone(),
5361            Arc::clone(&self.factory),
5362            Some(Arc::clone(&self.hash_pool)),
5363            Arc::clone(&self.counters),
5364        )
5365        .await?;
5366        // M147: Spawn background metadata resolver before registering.
5367        // This races against the TorrentActor's own FetchingMetadata phase —
5368        // first to resolve wins.
5369        self.spawn_metadata_resolver(info_hash, &handle);
5370
5371        self.torrents.insert(
5372            info_hash,
5373            TorrentEntry {
5374                handle,
5375                queue_position: -1,
5376                auto_managed: true,
5377                started_at: Some(tokio::time::Instant::now()),
5378                smoothed_download_rate: f64::MAX,
5379                smoothed_upload_rate: f64::MAX,
5380            },
5381        );
5382        self.info_hash_registry.insert(info_hash, ());
5383
5384        // Assign queue position for auto-managed torrents
5385        let pos = self.next_queue_position();
5386        if let Some(entry) = self.torrents.get_mut(&info_hash)
5387            && entry.auto_managed
5388        {
5389            entry.queue_position = pos;
5390        }
5391
5392        info!(%info_hash, "magnet torrent added to session");
5393        post_alert(
5394            &self.alert_tx,
5395            &self.alert_mask,
5396            AlertKind::TorrentAdded {
5397                info_hash,
5398                name: display_name,
5399            },
5400        );
5401        // BEP 27: magnet metadata not available yet — we allow this one-time LAN
5402        // announce. Once metadata resolves, all subsequent LSD ops are gated by
5403        // is_private() checks in ForceLsdAnnounce and lsd_peers_rx handlers.
5404        if let Some(ref lsd) = self.lsd {
5405            lsd.announce(vec![info_hash]).await;
5406        }
5407        Ok(info_hash)
5408    }
5409
5410    /// M147: Spawn a background task that pre-resolves magnet metadata via DHT.
5411    ///
5412    /// The resolver connects to peers discovered via DHT `get_peers`, performs
5413    /// BT + BEP 10 extension + BEP 9 `ut_metadata` exchanges, and sends the
5414    /// assembled metadata back to the `TorrentActor` via `PreResolvedMetadata`.
5415    /// This races against the `TorrentActor`'s own `FetchingMetadata` phase.
5416    fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5417        let dht = match self.dht_v4 {
5418            Some(ref dht) => dht.clone(),
5419            None => return, // No DHT = skip background resolution
5420        };
5421        let factory = Arc::clone(&self.factory);
5422        let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5423        let handle = torrent_handle.clone();
5424
5425        tokio::spawn(async move {
5426            let peer_rx = match dht.get_peers(info_hash).await {
5427                Ok(rx) => rx,
5428                Err(e) => {
5429                    debug!(
5430                        %info_hash,
5431                        "metadata resolver: failed to start DHT get_peers: {e}"
5432                    );
5433                    return;
5434                }
5435            };
5436
5437            let peer_id = irontide_core::PeerId::generate().0;
5438            match crate::metadata_resolver::resolve_metadata(
5439                info_hash,
5440                peer_id,
5441                peer_rx,
5442                factory,
5443                connect_timeout,
5444                crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5445            )
5446            .await
5447            {
5448                Ok((meta, peers)) => {
5449                    let info_bytes = if let Some(b) = meta.info_bytes {
5450                        b.to_vec()
5451                    } else {
5452                        match irontide_bencode::to_bytes(&meta.info) {
5453                            Ok(bytes) => bytes,
5454                            Err(e) => {
5455                                debug!(
5456                                    %info_hash,
5457                                    "metadata resolver: failed to re-encode info dict: {e}"
5458                                );
5459                                return;
5460                            }
5461                        }
5462                    };
5463                    debug!(
5464                        %info_hash,
5465                        num_peers = peers.len(),
5466                        "metadata resolver: pre-resolved metadata, sending to torrent actor"
5467                    );
5468                    handle.send_pre_resolved_metadata(info_bytes, peers);
5469                }
5470                Err(e) => {
5471                    debug!(
5472                        %info_hash,
5473                        "metadata resolver: failed to resolve metadata: {e}"
5474                    );
5475                }
5476            }
5477        });
5478    }
5479
5480    async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5481        let entry = self
5482            .torrents
5483            .remove(&info_hash)
5484            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5485        self.info_hash_registry.remove(&info_hash);
5486        let was_auto_managed = entry.auto_managed;
5487        let removed_position = entry.queue_position;
5488        entry.handle.shutdown().await?;
5489        self.disk_manager.unregister_torrent(info_hash).await;
5490
5491        // Shift queue positions for remaining auto-managed torrents
5492        if was_auto_managed && removed_position >= 0 {
5493            let mut entries = self.queue_entries();
5494            let changed = crate::queue::remove_position(&mut entries, removed_position);
5495            self.apply_queue_changes(&changed);
5496        }
5497
5498        // Delete the resume file for this torrent so it is not restored
5499        // on the next startup. Errors are logged but not propagated — the
5500        // torrent is already removed from the in-memory state.
5501        let resume_dir = self.effective_resume_dir();
5502        if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5503            // NotFound is expected when no resume file was ever written.
5504            if e.kind() != std::io::ErrorKind::NotFound {
5505                warn!(%info_hash, "failed to delete resume file on removal: {e}");
5506            }
5507        }
5508
5509        info!(%info_hash, "torrent removed from session");
5510        post_alert(
5511            &self.alert_tx,
5512            &self.alert_mask,
5513            AlertKind::TorrentRemoved { info_hash },
5514        );
5515        Ok(())
5516    }
5517
5518    // ── M170 handlers ──────────────────────────────────────────────────
5519
5520    /// M170: unified add entry. Parses magnet or bytes, resolves the
5521    /// download directory via category precedence, and dispatches to the
5522    /// existing add paths.
5523    /// M223 — dispatcher for the `AddTorrentM170` recv arm. The bytes
5524    /// branch goes through the spawn-per-add path
5525    /// (`try_spawn_add_torrent`); the magnet branch stays inline because
5526    /// it is dominated by metadata fetch latency, not by
5527    /// `TorrentHandle::from_torrent` cost, and is out of M223 scope.
5528    /// Consumes `reply` directly — either sends an error synchronously
5529    /// or hands `reply` to the spawned prep task (which routes it back
5530    /// via `CommitAddTorrent`).
5531    async fn dispatch_add_torrent_m170(
5532        &mut self,
5533        params: AddTorrentParams,
5534        reply: oneshot::Sender<crate::Result<Id20>>,
5535    ) {
5536        // Resolve download_dir + prepare category label early so that
5537        // an unknown category fails fast (before any parsing work).
5538        let (resolved_dir, resolved_category) =
5539            match self.resolve_download_dir_and_category(&params) {
5540                Ok(x) => x,
5541                Err(e) => {
5542                    let _ = reply.send(Err(e));
5543                    return;
5544                }
5545            };
5546
5547        let AddTorrentParams {
5548            source,
5549            tags,
5550            paused,
5551            skip_checking: _, // reserved for M171+
5552            ..
5553        } = params;
5554
5555        // M226: resolve None → engine `default_add_paused`. `Some(v)` is an
5556        // explicit per-call override and wins over the engine setting.
5557        let paused = paused.unwrap_or(self.settings.default_add_paused);
5558
5559        match source {
5560            AddSource::Magnet(uri) => {
5561                // Magnet path: stays inline (out of M223 scope).
5562                let result: crate::Result<Id20> = async {
5563                    let magnet = irontide_core::Magnet::parse(&uri)?;
5564                    let info_hash = magnet.info_hash();
5565                    self.reject_if_in_deletion_grace(info_hash)?;
5566                    let id = self.handle_add_magnet(magnet, resolved_dir, tags).await?;
5567                    self.apply_post_add_m170(id, resolved_category, paused);
5568                    Ok(id)
5569                }
5570                .await;
5571                let _ = reply.send(result);
5572            }
5573            AddSource::Bytes(bytes) => {
5574                // Bytes path: spawn-per-add.
5575                let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5576                    let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5577                    let info_hash = meta
5578                        .as_v1()
5579                        .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5580                    self.reject_if_in_deletion_grace(info_hash)?;
5581                    if self.torrents.contains_key(&info_hash) {
5582                        return Err(crate::Error::DuplicateTorrent(info_hash));
5583                    }
5584                    if self.torrents.len() >= self.settings.max_torrents {
5585                        return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5586                    }
5587                    Ok(self.build_add_torrent_prep_bundle(
5588                        meta,
5589                        None,
5590                        resolved_dir,
5591                        tags,
5592                        Some(M170PostAdd {
5593                            category: resolved_category,
5594                            paused,
5595                        }),
5596                    ))
5597                })();
5598                match setup {
5599                    Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5600                    Err(e) => {
5601                        let _ = reply.send(Err(e));
5602                    }
5603                }
5604            }
5605        }
5606    }
5607
5608    /// Resolve the effective download directory + the category label to
5609    /// store on the `TorrentConfig`, following the M170 precedence rules.
5610    fn resolve_download_dir_and_category(
5611        &self,
5612        params: &AddTorrentParams,
5613    ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5614        match (&params.download_dir, &params.category) {
5615            (Some(explicit), cat) => {
5616                // Explicit path wins even when a category is set — qBt
5617                // preserves the category label either way.
5618                Ok((Some(explicit.clone()), cat.clone()))
5619            }
5620            (None, Some(name)) => {
5621                let registry = self.category_registry.read();
5622                match registry.get(name) {
5623                    Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5624                    None => Err(crate::Error::CategoryNotFound(name.clone())),
5625                }
5626            }
5627            (None, None) => Ok((None, None)),
5628        }
5629    }
5630
5631    /// Return an error if `info_hash` is currently being removed by a
5632    /// deleteFiles=true call in another task.
5633    fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5634        if self.deletion_grace.lock().contains(&info_hash) {
5635            return Err(crate::Error::TorrentBeingRemoved(info_hash));
5636        }
5637        Ok(())
5638    }
5639
5640    /// Post-add M170 hooks: stash the category label and mark the torrent
5641    /// paused if requested. Category is recorded for the initial stats
5642    /// snapshot; paused-on-add uses the existing pause path.
5643    fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5644        if let Some(entry) = self.torrents.get(&info_hash) {
5645            // Category label: stored on the torrent handle's config mirror
5646            // so future `stats()` calls include it. Fire-and-forget
5647            // because the field is non-load-bearing for the add path.
5648            if let Some(name) = category {
5649                let handle = entry.handle.clone();
5650                tokio::spawn(async move {
5651                    if let Err(e) = handle.set_category(Some(name)).await {
5652                        warn!(%info_hash, "failed to propagate category: {e}");
5653                    }
5654                });
5655            }
5656            if paused {
5657                let handle = entry.handle.clone();
5658                tokio::spawn(async move {
5659                    if let Err(e) = handle.pause().await {
5660                        warn!(%info_hash, "failed to pause on add: {e}");
5661                    }
5662                });
5663            }
5664        }
5665    }
5666
5667    /// M170: create a category, persist the registry on success.
5668    async fn handle_create_category(
5669        &self,
5670        name: String,
5671        save_path: PathBuf,
5672    ) -> Result<(), crate::category_manager::CategoryError> {
5673        {
5674            let mut registry = self.category_registry.write();
5675            registry.create(name, save_path)?;
5676        }
5677        self.persist_category_registry().await
5678    }
5679
5680    /// M170: edit a category, persist the registry on success.
5681    async fn handle_edit_category(
5682        &self,
5683        name: String,
5684        save_path: PathBuf,
5685    ) -> Result<(), crate::category_manager::CategoryError> {
5686        {
5687            let mut registry = self.category_registry.write();
5688            registry.edit(&name, save_path)?;
5689        }
5690        self.persist_category_registry().await
5691    }
5692
5693    /// M170: remove categories, clear labels on affected torrents, and
5694    /// persist the registry. Returns the names that were actually
5695    /// removed so the CLI / qBt handler can log or echo them.
5696    async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
5697        let removed: Vec<String> = {
5698            let mut registry = self.category_registry.write();
5699            registry.remove(&names)
5700        };
5701        if removed.is_empty() {
5702            return removed;
5703        }
5704
5705        // Clear the `category` label on every torrent assigned to a
5706        // removed name. Fire-and-forget per torrent — failure only
5707        // costs us label-sync (not data).
5708        for entry in self.torrents.values() {
5709            let handle = entry.handle.clone();
5710            let to_check: Vec<String> = removed.clone();
5711            tokio::spawn(async move {
5712                if let Ok(stats) = handle.stats().await
5713                    && let Some(current) = stats.category
5714                    && to_check.iter().any(|n| n.as_str() == current.as_str())
5715                    && let Err(e) = handle.set_category(None).await
5716                {
5717                    warn!(
5718                        cat = %current,
5719                        "failed to clear category label after removeCategories: {e}"
5720                    );
5721                }
5722            });
5723        }
5724
5725        if let Err(e) = self.persist_category_registry().await {
5726            warn!("failed to persist category registry after remove: {e}");
5727        }
5728        removed
5729    }
5730
5731    /// Spawn a blocking task to persist the registry to disk.
5732    async fn persist_category_registry(
5733        &self,
5734    ) -> Result<(), crate::category_manager::CategoryError> {
5735        let registry = Arc::clone(&self.category_registry);
5736        // Clone the current registry state out of the lock to avoid
5737        // holding it across the spawn_blocking boundary.
5738        let snapshot = registry.read().clone();
5739        tokio::task::spawn_blocking(move || snapshot.save())
5740            .await
5741            .map_err(|join_err| {
5742                crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
5743                    "category registry save join error: {join_err}"
5744                )))
5745            })?
5746    }
5747
5748    /// M171: delete a batch of tags. Returns the subset of names that
5749    /// were actually present at call time (unknown names are silently
5750    /// ignored, matching qBt `deleteTags`).
5751    ///
5752    /// After removal, any torrent carrying a deleted tag has that tag
5753    /// pruned from its label set via `TorrentHandle::set_tags`.
5754    async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
5755        let removed = {
5756            let mut reg = self.tag_registry.write();
5757            reg.delete(&names)
5758        };
5759        if !removed.is_empty() {
5760            let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
5761            for entry in self.torrents.values() {
5762                let handle = entry.handle.clone();
5763                let to_remove = to_remove.clone();
5764                tokio::spawn(async move {
5765                    if let Ok(stats) = handle.stats().await {
5766                        let new_tags: Vec<String> = stats
5767                            .tags
5768                            .into_iter()
5769                            .filter(|t| !to_remove.contains(t))
5770                            .collect();
5771                        if let Err(e) = handle.set_tags(new_tags).await {
5772                            tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
5773                        }
5774                    }
5775                });
5776            }
5777            if let Err(e) = self.persist_tag_registry().await {
5778                tracing::warn!(error = %e, "persist tag registry after DeleteTags");
5779            }
5780        }
5781        removed
5782    }
5783
5784    /// M171: add the given tags to each torrent in `info_hashes`. The
5785    /// engine-layer command is a wholesale replacement, so this reads
5786    /// the current tag set for each torrent, unions in the requested
5787    /// tags (sorted + deduped), and replays the result via
5788    /// `TorrentHandle::set_tags`. Unknown info hashes are silently
5789    /// skipped — qBt's `addTags` behaviour.
5790    ///
5791    /// # Errors
5792    ///
5793    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
5794    /// stopped while the batch was in flight.
5795    async fn handle_add_tags_to_torrents(
5796        &self,
5797        info_hashes: Vec<Id20>,
5798        tags_to_add: Vec<String>,
5799    ) -> crate::Result<()> {
5800        for hash in info_hashes {
5801            let Some(entry) = self.torrents.get(&hash) else {
5802                continue;
5803            };
5804            let current = entry.handle.stats().await?;
5805            let mut new_tags = current.tags;
5806            for t in &tags_to_add {
5807                if !new_tags.contains(t) {
5808                    new_tags.push(t.clone());
5809                }
5810            }
5811            new_tags.sort();
5812            new_tags.dedup();
5813            entry.handle.set_tags(new_tags).await?;
5814        }
5815        Ok(())
5816    }
5817
5818    /// M171: remove the given tags from each torrent in `info_hashes`.
5819    /// Unknown info hashes are silently skipped — qBt's `removeTags`
5820    /// behaviour.
5821    ///
5822    /// # Errors
5823    ///
5824    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
5825    /// stopped while the batch was in flight.
5826    async fn handle_remove_tags_from_torrents(
5827        &self,
5828        info_hashes: Vec<Id20>,
5829        tags_to_remove: Vec<String>,
5830    ) -> crate::Result<()> {
5831        for hash in info_hashes {
5832            let Some(entry) = self.torrents.get(&hash) else {
5833                continue;
5834            };
5835            let current = entry.handle.stats().await?;
5836            let new_tags: Vec<String> = current
5837                .tags
5838                .into_iter()
5839                .filter(|t| !tags_to_remove.contains(t))
5840                .collect();
5841            entry.handle.set_tags(new_tags).await?;
5842        }
5843        Ok(())
5844    }
5845
5846    /// M171: spawn a blocking task to persist the tag registry to disk.
5847    /// Mirrors `persist_category_registry`.
5848    async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
5849        let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
5850        tokio::task::spawn_blocking(move || to_save.save())
5851            .await
5852            .unwrap_or_else(|_| {
5853                Err(crate::tag_manager::TagError::Persistence(
5854                    std::io::Error::other("spawn_blocking failed"),
5855                ))
5856            })
5857    }
5858
5859    /// M170: remove a torrent and delete its files from disk.
5860    async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
5861        // v0.173.1: query the TorrentActor for metadata instead of reading
5862        // the deleted `TorrentEntry.meta` cache. For magnet torrents with
5863        // resolved metadata this returns the real file list (fixing the
5864        // v0.173.0 silent-no-op-on-disk bug). For magnets *still resolving*
5865        // we treat it as an empty file list and still proceed with the
5866        // session-level removal — matches the pre-v0.173.1 observable
5867        // behaviour that the *arr e2e test relies on (`deleteFiles=true` on
5868        // a pre-metadata magnet leaves `download_dir` untouched but cleanly
5869        // removes the torrent from the registry).
5870        let handle = {
5871            let entry = self
5872                .torrents
5873                .get(&info_hash)
5874                .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5875            entry.handle.clone()
5876        };
5877        let file_paths: Vec<PathBuf> = match handle.get_meta().await {
5878            Ok(Some(meta)) => meta
5879                .info
5880                .files()
5881                .iter()
5882                .map(|f| f.path.iter().collect::<PathBuf>())
5883                .collect(),
5884            // Meta not yet resolved (pre-metadata magnet) or actor shut down:
5885            // nothing to walk on disk. Session-level removal still proceeds.
5886            Ok(None) | Err(_) => Vec::new(),
5887        };
5888        let download_dir = self.settings.download_dir.clone();
5889        let _ = handle.pause().await;
5890
5891        // Enter the deletion grace window BEFORE dropping the in-memory
5892        // entry — any add that races in during the walk will see the
5893        // grace set and get 409'd.
5894        self.deletion_grace.lock().insert(info_hash);
5895
5896        // Remove from session (same as the existing path — closes
5897        // storage handles, deletes resume file, etc.).
5898        let remove_result = self.handle_remove_torrent(info_hash).await;
5899        if let Err(e) = &remove_result {
5900            warn!(
5901                %info_hash,
5902                error = %e,
5903                "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
5904            );
5905        }
5906
5907        // Now blast the files. `download_dir` is the session default (good
5908        // enough for M170 because add_torrent threads the same dir through
5909        // to storage). Once per-torrent save path is recorded (future
5910        // milestone), swap this out.
5911        let grace = Arc::clone(&self.deletion_grace);
5912        tokio::task::spawn_blocking(move || {
5913            irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
5914            grace.lock().remove(&info_hash);
5915        });
5916
5917        Ok(())
5918    }
5919
5920    async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5921        let entry = self
5922            .torrents
5923            .get(&info_hash)
5924            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5925        entry.handle.pause().await
5926    }
5927
5928    async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5929        let entry = self
5930            .torrents
5931            .get(&info_hash)
5932            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5933        entry.handle.resume().await
5934    }
5935
5936    async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5937        let entry = self
5938            .torrents
5939            .get(&info_hash)
5940            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5941        entry
5942            .handle
5943            .cmd_tx
5944            .send(crate::types::TorrentCommand::ForceResume)
5945            .await
5946            .map_err(|_| crate::Error::Shutdown)
5947    }
5948
5949    async fn handle_set_torrent_seed_ratio(
5950        &self,
5951        info_hash: Id20,
5952        limit: Option<f64>,
5953    ) -> crate::Result<()> {
5954        let entry = self
5955            .torrents
5956            .get(&info_hash)
5957            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5958        let (tx, rx) = oneshot::channel();
5959        entry
5960            .handle
5961            .cmd_tx
5962            .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
5963            .await
5964            .map_err(|_| crate::Error::Shutdown)?;
5965        rx.await.map_err(|_| crate::Error::Shutdown)
5966    }
5967
5968    async fn handle_move_torrent_storage(
5969        &self,
5970        info_hash: Id20,
5971        new_path: std::path::PathBuf,
5972    ) -> crate::Result<()> {
5973        let entry = self
5974            .torrents
5975            .get(&info_hash)
5976            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5977        entry.handle.move_storage(new_path).await
5978    }
5979
5980    async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
5981        let entry = self
5982            .torrents
5983            .get(&info_hash)
5984            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5985        let mut stats = entry.handle.stats().await?;
5986        // Enrich with session-level data that the torrent actor doesn't own.
5987        stats.queue_position = entry.queue_position;
5988        stats.auto_managed = entry.auto_managed;
5989        Ok(stats)
5990    }
5991
5992    async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
5993        // v0.173.1: queries the TorrentActor (single source of truth) instead
5994        // of reading the deleted `TorrentEntry.meta` cache. For magnet torrents
5995        // this returns the real meta once assembled, where v0.173.0 always saw
5996        // `None` → 404 on `/api/v2/torrents/files`.
5997        let meta = self.get_entry_meta(info_hash).await?;
5998        let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
5999            file_list
6000                .iter()
6001                .map(|f| FileInfo {
6002                    path: f.path.iter().collect::<PathBuf>(),
6003                    length: f.length,
6004                })
6005                .collect()
6006        } else {
6007            vec![FileInfo {
6008                path: PathBuf::from(&meta.info.name),
6009                length: meta.info.total_length(),
6010            }]
6011        };
6012
6013        Ok(TorrentInfo {
6014            info_hash,
6015            name: meta.info.name.clone(),
6016            total_length: meta.info.total_length(),
6017            piece_length: meta.info.piece_length,
6018            num_pieces: meta.info.num_pieces() as u32,
6019            files,
6020            private: meta.info.private == Some(1),
6021        })
6022    }
6023
6024    /// Update gauge metrics that come from session-level state.
6025    fn update_session_gauges(&self) {
6026        use crate::stats::{
6027            DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6028            SES_NUM_TORRENTS,
6029        };
6030        let c = &self.counters;
6031        c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6032        c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6033
6034        // DHT presence (instance count, not routing table size)
6035        let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6036        c.set(DHT_NODES, dht_nodes);
6037        c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6038        c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6039
6040        // Ban count
6041        let ban_count = self.ban_manager.read().banned_list().len() as i64;
6042        c.set(PEER_NUM_BANNED, ban_count);
6043    }
6044
6045    /// Snapshot counters and fire a `SessionStatsAlert`.
6046    fn fire_stats_alert(&self) {
6047        self.update_session_gauges();
6048        let values = self.counters.snapshot();
6049        crate::alert::post_alert(
6050            &self.alert_tx,
6051            &self.alert_mask,
6052            crate::alert::AlertKind::SessionStatsAlert { values },
6053        );
6054    }
6055
6056    /// Fire a periodic BEP 51 `sample_infohashes` query to the DHT (M111).
6057    async fn fire_sample_infohashes(&self) {
6058        let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6059            return;
6060        };
6061        let mut buf = [0u8; 20];
6062        irontide_core::random_bytes(&mut buf);
6063        let target = Id20::from(buf);
6064        match dht.sample_infohashes(target).await {
6065            Ok(result) => {
6066                post_alert(
6067                    &self.alert_tx,
6068                    &self.alert_mask,
6069                    AlertKind::DhtSampleInfohashes {
6070                        num_samples: result.samples.len(),
6071                        total_estimate: result.num,
6072                    },
6073                );
6074            }
6075            Err(e) => {
6076                debug!("sample_infohashes failed: {e}");
6077            }
6078        }
6079    }
6080
6081    async fn make_session_stats(&self) -> SessionStats {
6082        self.update_session_gauges();
6083
6084        let mut total_downloaded = 0u64;
6085        let mut total_uploaded = 0u64;
6086
6087        for entry in self.torrents.values() {
6088            if let Ok(stats) = entry.handle.stats().await {
6089                total_downloaded += stats.downloaded;
6090                total_uploaded += stats.uploaded;
6091            }
6092        }
6093
6094        SessionStats {
6095            active_torrents: self.torrents.len(),
6096            total_downloaded,
6097            total_uploaded,
6098            dht_nodes: usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some()),
6099        }
6100    }
6101
6102    /// Build a debug state snapshot across all torrents, collecting per-torrent
6103    /// stats and per-peer details. Torrents that time out are skipped so a
6104    /// single slow actor never blocks the whole response.
6105    async fn make_debug_state(&self) -> crate::types::DebugState {
6106        use crate::stats::{
6107            DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6108            DISPATCH_NOTIFY_WAKEUP_TOTAL,
6109        };
6110
6111        // Session-wide dispatch counters from the atomic counters array.
6112        let snap = self.counters.snapshot();
6113        let dispatch = crate::types::DebugDispatchState {
6114            acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6115            acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6116            acquire_us: snap[DISPATCH_ACQUIRE_US],
6117            notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6118            pieces_queued: 0,
6119            pieces_inflight: 0,
6120        };
6121
6122        let mut torrents = Vec::with_capacity(self.torrents.len());
6123        for (&info_hash, entry) in &self.torrents {
6124            // Per-torrent stats — skip if the actor is slow.
6125            let Ok(Ok(stats)) =
6126                tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6127                    .await
6128            else {
6129                continue;
6130            };
6131
6132            // Per-peer details — skip on timeout.
6133            let peers_raw = match tokio::time::timeout(
6134                std::time::Duration::from_millis(500),
6135                entry.handle.get_peer_info(),
6136            )
6137            .await
6138            {
6139                Ok(Ok(p)) => p,
6140                _ => Vec::new(),
6141            };
6142
6143            let peers: Vec<crate::types::DebugPeerState> = peers_raw
6144                .iter()
6145                .map(|p| crate::types::DebugPeerState {
6146                    addr: p.addr,
6147                    in_flight: p.in_flight_requests,
6148                    target_depth: p.target_pipeline_depth,
6149                    choking: p.peer_choking,
6150                    download_rate: p.download_rate,
6151                })
6152                .collect();
6153
6154            let mut per_torrent_dispatch = dispatch.clone();
6155            per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6156            per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6157
6158            torrents.push(crate::types::DebugTorrentState {
6159                info_hash: info_hash.to_hex(),
6160                state: format!("{:?}", stats.state),
6161                num_peers: stats.peers_connected,
6162                dispatch: per_torrent_dispatch,
6163                peers,
6164            });
6165        }
6166
6167        crate::types::DebugState { torrents }
6168    }
6169
6170    async fn handle_save_torrent_resume(
6171        &self,
6172        info_hash: Id20,
6173    ) -> crate::Result<irontide_core::FastResumeData> {
6174        let entry = self
6175            .torrents
6176            .get(&info_hash)
6177            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6178        let mut resume = entry.handle.save_resume_data().await?;
6179        // Patch in queue state from SessionActor's TorrentEntry (the
6180        // TorrentHandle doesn't know about queue position / auto-managed).
6181        resume.queue_position = i64::from(entry.queue_position);
6182        resume.auto_managed = i64::from(entry.auto_managed);
6183        Ok(resume)
6184    }
6185
6186    async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6187        use crate::persistence::SessionState;
6188
6189        let mut torrents = Vec::new();
6190        for (info_hash, entry) in &self.torrents {
6191            match entry.handle.save_resume_data().await {
6192                Ok(rd) => torrents.push(rd),
6193                Err(e) => {
6194                    warn!(%info_hash, "failed to save resume data: {e}");
6195                }
6196            }
6197        }
6198
6199        // Serialize smart ban state (scoped to drop RwLockReadGuard before awaits)
6200        let (banned_peers, peer_strikes) = {
6201            let ban_mgr = self.ban_manager.read();
6202            let banned_peers: Vec<String> = ban_mgr
6203                .banned_list()
6204                .iter()
6205                .map(std::string::ToString::to_string)
6206                .collect();
6207            let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6208                .strikes_map()
6209                .iter()
6210                .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6211                    ip: ip.to_string(),
6212                    count: i64::from(count),
6213                })
6214                .collect();
6215            (banned_peers, peer_strikes)
6216        };
6217
6218        let mut dht_entries = Vec::new();
6219        let mut dht_node_id = None;
6220        if let Some(ref dht) = self.dht_v4 {
6221            // Save the (possibly BEP 42-regenerated) node ID for next session
6222            if let Ok(stats) = dht.stats().await {
6223                dht_node_id = Some(stats.node_id.to_hex());
6224            }
6225            for (_id, addr) in dht.get_routing_nodes().await {
6226                dht_entries.push(crate::persistence::DhtNodeEntry {
6227                    host: addr.ip().to_string(),
6228                    port: i64::from(addr.port()),
6229                });
6230            }
6231        }
6232        if let Some(ref dht) = self.dht_v6 {
6233            for (_id, addr) in dht.get_routing_nodes().await {
6234                dht_entries.push(crate::persistence::DhtNodeEntry {
6235                    host: addr.ip().to_string(),
6236                    port: i64::from(addr.port()),
6237                });
6238            }
6239        }
6240
6241        Ok(SessionState {
6242            dht_nodes: dht_entries,
6243            dht_node_id,
6244            torrents,
6245            banned_peers,
6246            peer_strikes,
6247        })
6248    }
6249
6250    /// Compute the effective resume data directory from settings.
6251    fn effective_resume_dir(&self) -> PathBuf {
6252        self.settings
6253            .resume_data_dir
6254            .clone()
6255            .unwrap_or_else(crate::resume_file::default_resume_dir)
6256    }
6257
6258    /// Load and restore torrents from per-torrent resume files on disk.
6259    ///
6260    /// Scans the resume directory, deserializes each `.resume` file, reconstructs
6261    /// the torrent metadata or magnet, adds it to the session, and restores the
6262    /// piece bitmap where available.
6263    async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6264        let resume_dir = self.effective_resume_dir();
6265        let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6266
6267        let mut restored = 0usize;
6268        let mut skipped = 0usize;
6269        let mut failed = 0usize;
6270
6271        for path in &paths {
6272            let file_name = path
6273                .file_name()
6274                .and_then(|n| n.to_str())
6275                .unwrap_or("<unknown>");
6276
6277            // Read and deserialize
6278            let bytes = match std::fs::read(path) {
6279                Ok(b) => b,
6280                Err(e) => {
6281                    warn!(file = %file_name, "failed to read resume file: {e}");
6282                    failed = failed.saturating_add(1);
6283                    continue;
6284                }
6285            };
6286
6287            let rd = match crate::resume_file::deserialize_resume(&bytes) {
6288                Ok(rd) => rd,
6289                Err(e) => {
6290                    warn!(file = %file_name, "failed to deserialize resume file: {e}");
6291                    failed = failed.saturating_add(1);
6292                    continue;
6293                }
6294            };
6295
6296            // Try to reconstruct as a resolved torrent (info dict present).
6297            if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6298                let info_hash = meta.info_hash;
6299                let pieces = rd.pieces.clone();
6300                let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6301
6302                // Restore to the original save_path (per-torrent download dir).
6303                let restore_dir = if rd.save_path.is_empty() {
6304                    None
6305                } else {
6306                    Some(PathBuf::from(&rd.save_path))
6307                };
6308                // M171: restore tags by baking them into the TorrentConfig
6309                // at add-time, matching the `AddTorrentParams::with_tags`
6310                // semantics. Category still goes through the post-add
6311                // fire-and-forget path (M170 behaviour preserved).
6312                let restore_tags = rd.tags.clone();
6313                match self
6314                    .handle_add_torrent(torrent_meta, None, restore_dir, restore_tags)
6315                    .await
6316                {
6317                    Ok(added_hash) => {
6318                        // Restore the piece bitmap if non-empty.
6319                        if !pieces.is_empty()
6320                            && let Some(entry) = self.torrents.get(&added_hash)
6321                            && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6322                        {
6323                            warn!(
6324                                %info_hash,
6325                                "failed to restore piece bitmap, torrent will recheck: {e}"
6326                            );
6327                        }
6328                        // M170: restore the category label if persisted.
6329                        if let Some(ref cat) = rd.category
6330                            && let Some(entry) = self.torrents.get(&added_hash)
6331                        {
6332                            let handle = entry.handle.clone();
6333                            let cat_owned = cat.clone();
6334                            tokio::spawn(async move {
6335                                let _ = handle.set_category(Some(cat_owned)).await;
6336                            });
6337                        }
6338                        // M178: restore per-URL web-seed stats so cumulative
6339                        // bytes and error history survive app restart.
6340                        if !rd.web_seed_stats.is_empty()
6341                            && let Some(entry) = self.torrents.get(&added_hash)
6342                        {
6343                            let handle = entry.handle.clone();
6344                            let stats_owned = rd.web_seed_stats.clone();
6345                            tokio::spawn(async move {
6346                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6347                            });
6348                        }
6349                        if self.settings.queueing_enabled
6350                            && let Some(entry) = self.torrents.get(&added_hash)
6351                        {
6352                            let _ = entry.handle.queue().await;
6353                        }
6354                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6355                            entry.queue_position = rd.queue_position as i32;
6356                            entry.auto_managed = rd.auto_managed != 0;
6357                        }
6358                        info!(%info_hash, "restored torrent from resume file");
6359                        restored = restored.saturating_add(1);
6360                    }
6361                    Err(crate::Error::DuplicateTorrent(_)) => {
6362                        debug!(%info_hash, "skipped duplicate torrent from resume");
6363                        skipped = skipped.saturating_add(1);
6364                    }
6365                    Err(e) => {
6366                        warn!(%info_hash, "failed to add restored torrent: {e}");
6367                        failed = failed.saturating_add(1);
6368                    }
6369                }
6370            } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6371                // Unresolved magnet: re-add as magnet link.
6372                let info_hash = magnet.info_hash();
6373                let restore_dir = if rd.save_path.is_empty() {
6374                    None
6375                } else {
6376                    Some(PathBuf::from(&rd.save_path))
6377                };
6378                // M171: restore tags via the add-time config bake path.
6379                let restore_tags = rd.tags.clone();
6380                match self
6381                    .handle_add_magnet(magnet, restore_dir, restore_tags)
6382                    .await
6383                {
6384                    Ok(added_hash) => {
6385                        // M170: restore category on magnet too.
6386                        if let Some(ref cat) = rd.category
6387                            && let Some(entry) = self.torrents.get(&added_hash)
6388                        {
6389                            let handle = entry.handle.clone();
6390                            let cat_owned = cat.clone();
6391                            tokio::spawn(async move {
6392                                let _ = handle.set_category(Some(cat_owned)).await;
6393                            });
6394                        }
6395                        // M178: restore per-URL web-seed stats on magnet too,
6396                        // so resumes from a magnet-added torrent that already
6397                        // had web-seed activity recover correctly.
6398                        if !rd.web_seed_stats.is_empty()
6399                            && let Some(entry) = self.torrents.get(&added_hash)
6400                        {
6401                            let handle = entry.handle.clone();
6402                            let stats_owned = rd.web_seed_stats.clone();
6403                            tokio::spawn(async move {
6404                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6405                            });
6406                        }
6407                        if self.settings.queueing_enabled
6408                            && let Some(entry) = self.torrents.get(&added_hash)
6409                        {
6410                            let _ = entry.handle.queue().await;
6411                        }
6412                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6413                            entry.queue_position = rd.queue_position as i32;
6414                            entry.auto_managed = rd.auto_managed != 0;
6415                        }
6416                        info!(%info_hash, "restored magnet from resume file");
6417                        restored = restored.saturating_add(1);
6418                    }
6419                    Err(crate::Error::DuplicateTorrent(_)) => {
6420                        debug!(%info_hash, "skipped duplicate magnet from resume");
6421                        skipped = skipped.saturating_add(1);
6422                    }
6423                    Err(e) => {
6424                        warn!(%info_hash, "failed to add restored magnet: {e}");
6425                        failed = failed.saturating_add(1);
6426                    }
6427                }
6428            } else {
6429                warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6430                failed = failed.saturating_add(1);
6431            }
6432        }
6433
6434        // Renormalize queue positions to contiguous 0..N-1. Handles
6435        // duplicate positions from crash mid-save, manual edits, or
6436        // older resume formats that default all positions to 0.
6437        {
6438            let mut entries: Vec<(Id20, i32)> = self
6439                .torrents
6440                .iter()
6441                .filter(|(_, e)| e.auto_managed)
6442                .map(|(h, e)| (*h, e.queue_position))
6443                .collect();
6444            entries.sort_by_key(|&(_, pos)| pos);
6445            for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6446                if let Some(entry) = self.torrents.get_mut(&hash) {
6447                    entry.queue_position = new_pos as i32;
6448                }
6449            }
6450        }
6451
6452        info!(restored, skipped, failed, "resume state loaded");
6453        Ok(ResumeLoadResult {
6454            restored,
6455            skipped,
6456            failed,
6457        })
6458    }
6459
6460    /// Save resume files for all torrents with a dirty `need_save_resume` flag.
6461    ///
6462    /// Returns the number of resume files successfully written.
6463    async fn save_dirty_resume_files(&mut self) -> usize {
6464        let resume_dir = self.effective_resume_dir();
6465
6466        if let Err(e) = std::fs::create_dir_all(resume_dir.join("torrents")) {
6467            warn!("failed to create resume dir: {e}");
6468            return 0;
6469        }
6470
6471        let mut saved = 0usize;
6472        // Collect info_hashes first to avoid borrow conflict with `self`.
6473        let info_hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6474
6475        for info_hash in &info_hashes {
6476            let Some(entry) = self.torrents.get(info_hash) else {
6477                continue;
6478            };
6479
6480            // Check dirty flag via stats
6481            let needs_save = match entry.handle.stats().await {
6482                Ok(stats) => stats.need_save_resume,
6483                Err(_) => continue,
6484            };
6485            if !needs_save {
6486                continue;
6487            }
6488
6489            // Build resume data and patch in session-level queue state.
6490            let mut rd = match entry.handle.save_resume_data().await {
6491                Ok(rd) => rd,
6492                Err(e) => {
6493                    warn!(%info_hash, "failed to build resume data: {e}");
6494                    continue;
6495                }
6496            };
6497            rd.queue_position = i64::from(entry.queue_position);
6498            rd.auto_managed = i64::from(entry.auto_managed);
6499
6500            // Serialize
6501            let bytes = match crate::resume_file::serialize_resume(&rd) {
6502                Ok(b) => b,
6503                Err(e) => {
6504                    warn!(%info_hash, "failed to serialize resume data: {e}");
6505                    continue;
6506                }
6507            };
6508
6509            // Atomic write
6510            let path = crate::resume_file::resume_file_path(&resume_dir, info_hash);
6511            if let Err(e) = crate::resume_file::atomic_write(&path, &bytes) {
6512                warn!(%info_hash, "failed to write resume file: {e}");
6513                continue;
6514            }
6515
6516            // Reset dirty flag
6517            if let Err(e) = entry.handle.clear_save_resume_flag().await {
6518                warn!(%info_hash, "failed to clear save_resume flag: {e}");
6519            }
6520
6521            saved = saved.saturating_add(1);
6522        }
6523        saved
6524    }
6525
6526    /// Apply new settings at runtime, transactionally (M173 Lane B, B1).
6527    ///
6528    /// Phases (executed in order; rollback in REVERSE on first failure):
6529    ///
6530    /// 1. Rate limits + alert mask (cheap; rollback restores)
6531    /// 2. Listen-port rebind (B4 wires the real reconfig — B1 stub no-op)
6532    /// 3. DHT enable/disable (B5-B7 wire — B1 stub no-op)
6533    /// 4. LSD enable/disable (B9 wires — B1 stub no-op)
6534    ///
6535    /// On any phase failure, already-applied phases roll back (LIFO) and
6536    /// the caller sees the original [`crate::Error`] that triggered the
6537    /// failure. Until B4-B9 land, phases 2-4 are no-ops, so this method
6538    /// behaves identically to the M171 implementation for callers that
6539    /// only patch rate limits / alert mask. Listen-port / DHT / LSD
6540    /// changes are still classified as "`restart_required`" by
6541    /// [`classify_restart_required`] until B10 graduates them.
6542    ///
6543    /// # Errors
6544    ///
6545    /// Returns [`crate::Error::InvalidSettings`] if `Settings::validate`
6546    /// rejects the new patch. Future variants from B4-B9 will surface
6547    /// listener / DHT / LSD restart failures via [`crate::apply::ApplyError`].
6548    fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
6549        // Validate FIRST so that an invalid patch never even enters the
6550        // transactional pipeline. This matches the M171 behaviour.
6551        new.validate()?;
6552
6553        // Snapshot pre-call values for rollback closures. These are
6554        // captured by-value so the rollback closures can run without
6555        // borrowing `self` (the executor needs `&mut self`).
6556        let old_upload_rate = self.settings.upload_rate_limit;
6557        let old_download_rate = self.settings.download_rate_limit;
6558        let old_alert_mask = self.settings.alert_mask;
6559        let old_settings = self.settings.clone();
6560        let old_settings_for_delta = self.settings.clone();
6561
6562        let new_upload_rate = new.upload_rate_limit;
6563        let new_download_rate = new.download_rate_limit;
6564        let new_alert_mask = new.alert_mask;
6565
6566        // Phase 1: rate limits + alert mask + Settings struct.
6567        // The rollback restores all four mutations (rate buckets +
6568        // mask atomic + Settings struct) — Settings is cloned for
6569        // restoration so the caller sees the original on failure.
6570        let upload_bucket = Arc::clone(&self.global_upload_bucket);
6571        let download_bucket = Arc::clone(&self.global_download_bucket);
6572        let alert_mask = Arc::clone(&self.alert_mask);
6573
6574        let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
6575            name: "rate_limits_and_mask",
6576            forward: Box::new(move |this: &mut Self| {
6577                if new_upload_rate != old_upload_rate {
6578                    upload_bucket.lock().set_rate(new_upload_rate);
6579                }
6580                if new_download_rate != old_download_rate {
6581                    download_bucket.lock().set_rate(new_download_rate);
6582                }
6583                if new_alert_mask != old_alert_mask {
6584                    alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
6585                }
6586                this.settings = new;
6587                Ok(())
6588            }),
6589            rollback: Box::new(move |this: &mut Self| {
6590                // Restore in the reverse order of forward mutations.
6591                this.settings = old_settings;
6592                if new_alert_mask != old_alert_mask {
6593                    this.alert_mask
6594                        .store(old_alert_mask.bits(), Ordering::Relaxed);
6595                }
6596                if new_download_rate != old_download_rate {
6597                    this.global_download_bucket
6598                        .lock()
6599                        .set_rate(old_download_rate);
6600                }
6601                if new_upload_rate != old_upload_rate {
6602                    this.global_upload_bucket.lock().set_rate(old_upload_rate);
6603                }
6604            }),
6605        };
6606
6607        // Phase 2 (listen_port) and Phase 4 (LSD) remain unimplemented.
6608        let phases = vec![phase1];
6609
6610        match crate::apply::apply_phases_with_rollback(self, phases) {
6611            Ok(()) => {
6612                // M226 Step 5: broadcast the new Settings to the
6613                // notification dispatcher. `send` on a watch channel
6614                // returns Err only when every receiver has dropped,
6615                // which means the dispatcher has already exited — at
6616                // that point the toggle is academic and we silently
6617                // discard the error so apply_settings stays infallible
6618                // on the notification axis.
6619                let _ = self.notification_settings_tx.send(self.settings.clone());
6620
6621                // Phase 3: DHT toggle (v0.187.1).
6622                if (old_settings_for_delta.enable_dht != self.settings.enable_dht
6623                    || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
6624                    && (!self.settings.enable_dht || self.settings.anonymous_mode)
6625                {
6626                    tracing::info!("DHT disabled via settings");
6627                    self.dht_v4 = None;
6628                    self.dht_v6 = None;
6629                    self.dht_v4_broadcast.replace(None);
6630                    self.dht_v6_broadcast.replace(None);
6631                }
6632
6633                // M224 D3: keep the listener's cap atomic in sync. The
6634                // listener reads this on every TCP accept, so the new cap
6635                // applies to the next accepted connection without a restart.
6636                // Update is unconditional — `store` is cheap, and skipping
6637                // when unchanged would require an additional read first.
6638                self.max_connections_global.store(
6639                    self.settings.max_connections_global,
6640                    std::sync::atomic::Ordering::SeqCst,
6641                );
6642
6643                let delta =
6644                    crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
6645                if delta.save_resume_interval_secs.is_some() {
6646                    self.resume_save_notify.notify_one();
6647                }
6648                if let Some(enabled) = delta.ip_filter_enabled {
6649                    self.ip_filter.write().enabled = enabled;
6650                }
6651                // M226 Step 6: poke the watched-folder dispatcher to
6652                // rebuild its debouncer against the new path. We only
6653                // fire when the path itself OR the delete-after-add
6654                // flag changed — rate-limit / DHT tweaks must NOT
6655                // churn inotify FDs.
6656                if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
6657                    self.watched_folder_changed.notify_one();
6658                }
6659                if !delta.is_empty() {
6660                    // v0.187.3 / 1A: SettingsDelta fan-out. We use try_send so a
6661                    // saturated per-torrent channel doesn't block the apply call;
6662                    // the trade-off is that the dropped torrent will retain its
6663                    // old config until the next apply. Pre-v0.187.3 this was a
6664                    // silent `let _`; now we log at WARN so partial-failure
6665                    // events are observable without becoming fatal.
6666                    let mut failed: Vec<irontide_core::Id20> = Vec::new();
6667                    for (hash, entry) in &self.torrents {
6668                        if entry
6669                            .handle
6670                            .cmd_tx
6671                            .try_send(crate::types::TorrentCommand::UpdateSettings(delta.clone()))
6672                            .is_err()
6673                        {
6674                            failed.push(*hash);
6675                        }
6676                    }
6677                    if !failed.is_empty() {
6678                        tracing::warn!(
6679                            count = failed.len(),
6680                            "SettingsDelta fan-out: per-torrent channel saturated; \
6681                             affected torrents will pick up the change on the next apply"
6682                        );
6683                    }
6684                }
6685                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
6686                Ok(())
6687            }
6688            Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
6689                Err(crate::Error::InvalidSettings(msg))
6690            }
6691            Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
6692        }
6693    }
6694
6695    /// Build a `QueueEntry` snapshot from current auto-managed torrents.
6696    fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
6697        self.torrents
6698            .iter()
6699            .filter(|(_, e)| e.auto_managed)
6700            .map(|(&hash, e)| crate::queue::QueueEntry {
6701                info_hash: hash,
6702                position: e.queue_position,
6703            })
6704            .collect()
6705    }
6706
6707    fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
6708        if !self.torrents.contains_key(&info_hash) {
6709            return Err(crate::Error::TorrentNotFound(info_hash));
6710        }
6711        let mut entries = self.queue_entries();
6712        let changed = crate::queue::set_position(&mut entries, info_hash, pos);
6713        self.apply_queue_changes(&changed);
6714        Ok(())
6715    }
6716
6717    fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
6718        if !self.torrents.contains_key(&info_hash) {
6719            return Err(crate::Error::TorrentNotFound(info_hash));
6720        }
6721        let mut entries = self.queue_entries();
6722        let changed = op(&mut entries, info_hash);
6723        self.apply_queue_changes(&changed);
6724        Ok(())
6725    }
6726
6727    /// Apply position changes back to `TorrentEntry` fields and fire alerts.
6728    fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
6729        for &(hash, old_pos, new_pos) in changed {
6730            if let Some(entry) = self.torrents.get_mut(&hash) {
6731                entry.queue_position = new_pos;
6732            }
6733            crate::alert::post_alert(
6734                &self.alert_tx,
6735                &self.alert_mask,
6736                crate::alert::AlertKind::TorrentQueuePositionChanged {
6737                    info_hash: hash,
6738                    old_pos,
6739                    new_pos,
6740                },
6741            );
6742        }
6743    }
6744
6745    async fn evaluate_queue(&mut self) {
6746        if !self.settings.queueing_enabled {
6747            return;
6748        }
6749        let now = tokio::time::Instant::now();
6750        let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
6751        let mut candidates = Vec::new();
6752
6753        // Collect info hashes first to avoid borrow issues with async calls
6754        let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6755
6756        for &info_hash in &hashes {
6757            let (queue_position, started_at) = {
6758                let Some(entry) = self.torrents.get(&info_hash) else {
6759                    continue;
6760                };
6761                if !entry.auto_managed {
6762                    continue;
6763                }
6764                (entry.queue_position, entry.started_at)
6765            };
6766
6767            // Get current stats (async call — self.torrents is not borrowed here)
6768            let stats = match self.torrents.get(&info_hash) {
6769                Some(entry) => match entry.handle.stats().await {
6770                    Ok(s) => s,
6771                    Err(_) => continue,
6772                },
6773                None => continue,
6774            };
6775
6776            let category = match stats.state {
6777                TorrentState::Checking | TorrentState::FetchingMetadata => {
6778                    crate::queue::QueueCategory::Checking
6779                }
6780                TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
6781                TorrentState::Seeding | TorrentState::Complete => {
6782                    crate::queue::QueueCategory::Seeding
6783                }
6784                TorrentState::Queued => {
6785                    if stats.progress >= 1.0 {
6786                        crate::queue::QueueCategory::Seeding
6787                    } else {
6788                        crate::queue::QueueCategory::Downloading
6789                    }
6790                }
6791                TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
6792            };
6793
6794            let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
6795
6796            // EWMA-smooth the rates for stable inactive classification.
6797            let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
6798            let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
6799            {
6800                let raw_dl = stats.download_rate as f64;
6801                let raw_ul = stats.upload_rate as f64;
6802                entry.smoothed_download_rate =
6803                    alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
6804                entry.smoothed_upload_rate =
6805                    alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
6806                (entry.smoothed_download_rate, entry.smoothed_upload_rate)
6807            } else {
6808                continue;
6809            };
6810
6811            let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
6812
6813            let is_inactive = past_startup
6814                && match category {
6815                    crate::queue::QueueCategory::Downloading => {
6816                        (smoothed_dl as u64) < self.settings.inactive_down_rate
6817                    }
6818                    crate::queue::QueueCategory::Seeding => {
6819                        (smoothed_ul as u64) < self.settings.inactive_up_rate
6820                    }
6821                    crate::queue::QueueCategory::Checking => false,
6822                };
6823
6824            let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
6825                std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
6826            } else {
6827                startup_duration
6828            };
6829            let recently_started =
6830                started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
6831
6832            let seed_rank = if category == crate::queue::QueueCategory::Seeding {
6833                Some(crate::queue::compute_seed_rank(
6834                    stats.num_complete,
6835                    stats.num_incomplete,
6836                ))
6837            } else {
6838                None
6839            };
6840
6841            candidates.push(crate::queue::QueueCandidate {
6842                info_hash,
6843                position: queue_position,
6844                category,
6845                is_active,
6846                is_inactive,
6847                recently_started,
6848                seed_rank,
6849            });
6850        }
6851
6852        let config = crate::queue::QueueConfig {
6853            active_downloads: self.settings.active_downloads,
6854            active_seeds: self.settings.active_seeds,
6855            active_checking: self.settings.active_checking,
6856            active_limit: self.settings.active_limit,
6857            dont_count_slow: self.settings.dont_count_slow_torrents,
6858            prefer_seeds: self.settings.auto_manage_prefer_seeds,
6859        };
6860        let mut decision = crate::queue::evaluate(&candidates, &config);
6861        crate::queue::apply_preemption(&mut decision, &candidates);
6862
6863        // Apply decisions
6864        for hash in &decision.to_pause {
6865            if let Some(entry) = self.torrents.get(hash) {
6866                let _ = entry.handle.queue().await;
6867            }
6868            post_alert(
6869                &self.alert_tx,
6870                &self.alert_mask,
6871                AlertKind::TorrentAutoManaged {
6872                    info_hash: *hash,
6873                    paused: true,
6874                },
6875            );
6876        }
6877
6878        for hash in &decision.to_resume {
6879            if let Some(entry) = self.torrents.get_mut(hash) {
6880                let _ = entry.handle.resume().await;
6881                entry.started_at = Some(tokio::time::Instant::now());
6882            }
6883            post_alert(
6884                &self.alert_tx,
6885                &self.alert_mask,
6886                AlertKind::TorrentAutoManaged {
6887                    info_hash: *hash,
6888                    paused: false,
6889                },
6890            );
6891        }
6892    }
6893
6894    /// Handle a pre-validated inbound connection from the `ListenerTask` (M114).
6895    fn handle_identified_inbound(&self, conn: crate::listener::IdentifiedConnection) {
6896        if let Some(entry) = self.torrents.get(&conn.info_hash) {
6897            debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
6898            let handle = entry.handle.clone();
6899            tokio::spawn(async move {
6900                let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
6901            });
6902        } else {
6903            // Race: torrent removed between validation and receipt.
6904            debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
6905        }
6906    }
6907
6908    /// Handle an incoming SSL/TLS connection (M42).
6909    ///
6910    /// Uses `LazyConfigAcceptor` to peek at the TLS `ClientHello` and extract
6911    /// the SNI (hex-encoded info hash) to route the connection to the right
6912    /// torrent. The full TLS handshake uses the torrent's CA cert to build
6913    /// the server config.
6914    async fn handle_ssl_incoming(
6915        &mut self,
6916        stream: crate::transport::BoxedStream,
6917        addr: std::net::SocketAddr,
6918    ) {
6919        use tokio_rustls::LazyConfigAcceptor;
6920
6921        let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
6922
6923        let start_handshake = match acceptor.await {
6924            Ok(sh) => sh,
6925            Err(e) => {
6926                debug!(%addr, error = %e, "SSL ClientHello read failed");
6927                return;
6928            }
6929        };
6930
6931        // Extract SNI from ClientHello
6932        let client_hello = start_handshake.client_hello();
6933        let sni = if let Some(name) = client_hello.server_name() {
6934            name.to_string()
6935        } else {
6936            debug!(%addr, "SSL connection missing SNI");
6937            return;
6938        };
6939
6940        // SNI is hex-encoded info hash (40 chars for SHA-1)
6941        let Ok(info_hash) = Id20::from_hex(&sni) else {
6942            debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
6943            return;
6944        };
6945
6946        // Look up the torrent
6947        let Some(torrent) = self.torrents.get(&info_hash) else {
6948            debug!(%addr, %info_hash, "SSL connection for unknown torrent");
6949            return;
6950        };
6951
6952        // Get the SSL CA cert from the torrent's metadata.
6953        //
6954        // v0.173.1: `TorrentEntry.meta` was deleted. Query the TorrentActor
6955        // directly (single source of truth). Magnet torrents previously hit
6956        // the "non-SSL torrent" branch here because their entry.meta was
6957        // always None — BEP 4A handshakes silently dropped.
6958        let meta = match torrent.handle.get_meta().await {
6959            Ok(Some(m)) => m,
6960            Ok(None) => {
6961                debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
6962                return;
6963            }
6964            Err(_) => {
6965                debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
6966                return;
6967            }
6968        };
6969        let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
6970            cert.clone()
6971        } else {
6972            debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
6973            return;
6974        };
6975
6976        // Build server config using the torrent's CA cert
6977        let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
6978            match mgr.server_config(&ssl_cert) {
6979                Ok(cfg) => cfg,
6980                Err(e) => {
6981                    warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
6982                    return;
6983                }
6984            }
6985        } else {
6986            debug!(%addr, "SSL manager not initialized");
6987            return;
6988        };
6989
6990        // Complete the TLS handshake
6991        let tls_stream = match start_handshake.into_stream(server_config).await {
6992            Ok(s) => s,
6993            Err(e) => {
6994                warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
6995                post_alert(
6996                    &self.alert_tx,
6997                    &self.alert_mask,
6998                    AlertKind::SslTorrentError {
6999                        info_hash,
7000                        message: format!("inbound TLS handshake from {addr}: {e}"),
7001                    },
7002                );
7003                return;
7004            }
7005        };
7006
7007        // Route to the torrent actor via SpawnSslPeer command
7008        let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7009    }
7010
7011    async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7012        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7013        match dht.put_immutable(value.clone()).await {
7014            Ok(target) => {
7015                post_alert(
7016                    &self.alert_tx,
7017                    &self.alert_mask,
7018                    AlertKind::DhtPutComplete { target },
7019                );
7020                Ok(target)
7021            }
7022            Err(e) => {
7023                let target = irontide_core::sha1(&value);
7024                post_alert(
7025                    &self.alert_tx,
7026                    &self.alert_mask,
7027                    AlertKind::DhtItemError {
7028                        target,
7029                        message: e.to_string(),
7030                    },
7031                );
7032                Err(crate::Error::Dht(e))
7033            }
7034        }
7035    }
7036
7037    async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7038        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7039        match dht.get_immutable(target).await {
7040            Ok(value) => {
7041                post_alert(
7042                    &self.alert_tx,
7043                    &self.alert_mask,
7044                    AlertKind::DhtGetResult {
7045                        target,
7046                        value: value.clone(),
7047                    },
7048                );
7049                Ok(value)
7050            }
7051            Err(e) => {
7052                post_alert(
7053                    &self.alert_tx,
7054                    &self.alert_mask,
7055                    AlertKind::DhtItemError {
7056                        target,
7057                        message: e.to_string(),
7058                    },
7059                );
7060                Err(crate::Error::Dht(e))
7061            }
7062        }
7063    }
7064
7065    async fn handle_dht_put_mutable(
7066        &self,
7067        keypair_bytes: [u8; 32],
7068        value: Vec<u8>,
7069        seq: i64,
7070        salt: Vec<u8>,
7071    ) -> crate::Result<Id20> {
7072        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7073        match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7074            Ok(target) => {
7075                post_alert(
7076                    &self.alert_tx,
7077                    &self.alert_mask,
7078                    AlertKind::DhtMutablePutComplete { target, seq },
7079                );
7080                Ok(target)
7081            }
7082            Err(e) => {
7083                post_alert(
7084                    &self.alert_tx,
7085                    &self.alert_mask,
7086                    AlertKind::DhtItemError {
7087                        target: Id20::from([0u8; 20]),
7088                        message: e.to_string(),
7089                    },
7090                );
7091                Err(crate::Error::Dht(e))
7092            }
7093        }
7094    }
7095
7096    async fn handle_dht_get_mutable(
7097        &self,
7098        public_key: [u8; 32],
7099        salt: Vec<u8>,
7100    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7101        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7102        let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7103        match dht.get_mutable(public_key, salt).await {
7104            Ok(result) => {
7105                let (value, seq) = match &result {
7106                    Some((v, s)) => (Some(v.clone()), Some(*s)),
7107                    None => (None, None),
7108                };
7109                post_alert(
7110                    &self.alert_tx,
7111                    &self.alert_mask,
7112                    AlertKind::DhtMutableGetResult {
7113                        target,
7114                        value,
7115                        seq,
7116                        public_key,
7117                    },
7118                );
7119                Ok(result)
7120            }
7121            Err(e) => {
7122                post_alert(
7123                    &self.alert_tx,
7124                    &self.alert_mask,
7125                    AlertKind::DhtItemError {
7126                        target,
7127                        message: e.to_string(),
7128                    },
7129                );
7130                Err(crate::Error::Dht(e))
7131            }
7132        }
7133    }
7134
7135    async fn shutdown_all(&mut self) {
7136        // Save resume files before draining torrents (M161 Phase 5).
7137        let save_count = self.save_dirty_resume_files().await;
7138        if save_count > 0 {
7139            info!(save_count, "saved resume files on shutdown");
7140        }
7141
7142        for (info_hash, entry) in self.torrents.drain() {
7143            debug!(%info_hash, "shutting down torrent");
7144            let _ = entry.handle.shutdown().await;
7145        }
7146        if let Some(ref dht) = self.dht_v4 {
7147            let _ = dht.shutdown().await;
7148        }
7149        if let Some(ref dht) = self.dht_v6 {
7150            let _ = dht.shutdown().await;
7151        }
7152        if let Some(ref nat) = self.nat {
7153            nat.shutdown().await;
7154        }
7155        if let Some(ref lsd) = self.lsd {
7156            lsd.shutdown().await;
7157        }
7158        if let Some(ref socket) = self.utp_socket
7159            && let Err(e) = socket.shutdown().await
7160        {
7161            debug!(error = %e, "uTP socket shutdown error");
7162        }
7163        if let Some(ref socket) = self.utp_socket_v6
7164            && let Err(e) = socket.shutdown().await
7165        {
7166            debug!(error = %e, "uTP v6 socket shutdown error");
7167        }
7168        self.disk_manager.shutdown().await;
7169    }
7170}
7171
7172/// Helper to receive NAT events from an optional receiver.
7173/// Returns `pending` if no receiver is available, so the `select!` branch is skipped.
7174async fn recv_nat_event(
7175    rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7176) -> irontide_nat::NatEvent {
7177    match rx {
7178        Some(r) => match r.recv().await {
7179            Some(event) => event,
7180            None => std::future::pending().await,
7181        },
7182        None => std::future::pending().await,
7183    }
7184}
7185
7186/// Receive from an optional DHT IP consensus channel, pending forever if absent.
7187async fn recv_dht_ip(
7188    rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7189) -> Option<std::net::IpAddr> {
7190    match rx {
7191        Some(r) => r.recv().await,
7192        None => std::future::pending().await,
7193    }
7194}
7195
7196/// Synthesize a minimal `TorrentMetaV1` from a `TorrentMetaV2` for session compatibility.
7197///
7198/// The session engine uses v1 structures internally (info hash as Id20, `InfoDict` for
7199/// piece hashing, etc.). For v2-only torrents, we create a "virtual" v1 representation
7200/// with the truncated SHA-256 hash as the `info_hash`.
7201/// M223 — off-actor add-torrent prep phase. Runs in a `tokio::spawn`'d
7202/// task launched by `SessionActor::try_spawn_add_torrent` so concurrent
7203/// adds do not serialise the actor's command queue. Performs the heavy
7204/// work that previously caused super-linear handler-cost growth in the
7205/// parallel-7 POST tail:
7206/// - synthesise / clone v1 metadata
7207/// - create the storage backend (filesystem or memory)
7208/// - `disk_manager.register_torrent` (async ack from disk actor)
7209/// - `TorrentHandle::from_torrent` (spawns the `TorrentActor`)
7210///
7211/// Returns `PreparedAddTorrent` on success — the `SessionActor` commit
7212/// arm then inserts it into `self.torrents` + queue position + alert +
7213/// LSD. Failures propagate untouched so the commit arm can reply with
7214/// the original error.
7215async fn prepare_add_torrent_off_actor(
7216    bundle: AddTorrentPrepBundle,
7217) -> crate::Result<PreparedAddTorrent> {
7218    let AddTorrentPrepBundle {
7219        torrent_meta,
7220        storage_override,
7221        torrent_config,
7222        disk_manager,
7223        dht_v4_broadcast,
7224        dht_v6_broadcast,
7225        global_up,
7226        global_down,
7227        slot_tuner,
7228        alert_tx,
7229        alert_mask,
7230        utp_socket,
7231        utp_socket_v6,
7232        ban_manager,
7233        ip_filter,
7234        plugins,
7235        sam_session,
7236        ssl_manager,
7237        factory,
7238        hash_pool,
7239        counters,
7240        m170_post,
7241    } = bundle;
7242
7243    let version = torrent_meta.version();
7244    let meta_v2 = torrent_meta.as_v2().cloned();
7245
7246    // For v2-only torrents, synthesize a minimal v1 metadata wrapper.
7247    // The session uses info_hash (Id20) as the primary key, so we use
7248    // the SHA-256 truncated to 20 bytes (per BEP 52 tracker/DHT compat).
7249    let meta = if let Some(v1) = torrent_meta.as_v1() {
7250        v1.clone()
7251    } else {
7252        let v2 = torrent_meta.as_v2().unwrap();
7253        synthesize_v1_from_v2(v2)
7254    };
7255    let info_hash = meta.info_hash;
7256    let is_private = meta.info.private == Some(1);
7257
7258    // Create or use provided storage, then register with disk manager
7259    let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7260        s
7261    } else {
7262        let lengths = Lengths::new(
7263            meta.info.total_length(),
7264            meta.info.piece_length,
7265            DEFAULT_CHUNK_SIZE,
7266        );
7267        let files = meta.info.files();
7268        let file_paths: Vec<PathBuf> = files
7269            .iter()
7270            .map(|f| f.path.iter().collect::<PathBuf>())
7271            .collect();
7272        let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7273        let prealloc_mode = torrent_config.preallocate_mode.unwrap_or_else(|| {
7274            irontide_storage::PreallocateMode::from(
7275                torrent_config.storage_mode == irontide_core::StorageMode::Full,
7276            )
7277        });
7278        match irontide_storage::FilesystemStorage::new(
7279            &torrent_config.download_dir,
7280            file_paths,
7281            file_lengths,
7282            lengths.clone(),
7283            None,
7284            prealloc_mode,
7285            torrent_config.filesystem_direct_io,
7286        ) {
7287            Ok(s) => Arc::new(s),
7288            Err(e) => {
7289                warn!("failed to create filesystem storage: {e}, falling back to memory");
7290                Arc::new(irontide_storage::MemoryStorage::new(lengths))
7291            }
7292        }
7293    };
7294    let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7295
7296    let handle = TorrentHandle::from_torrent(
7297        meta.clone(),
7298        version,
7299        meta_v2,
7300        disk_handle,
7301        disk_manager,
7302        torrent_config,
7303        dht_v4_broadcast.subscribe(),
7304        dht_v6_broadcast.subscribe(),
7305        global_up,
7306        global_down,
7307        slot_tuner,
7308        alert_tx.clone(),
7309        Arc::clone(&alert_mask),
7310        utp_socket,
7311        utp_socket_v6,
7312        ban_manager,
7313        ip_filter,
7314        plugins,
7315        sam_session,
7316        ssl_manager,
7317        factory,
7318        Some(hash_pool),
7319        counters,
7320    )
7321    .await?;
7322
7323    // M223 — post `TorrentAdded` here (sync, in the prep task) rather
7324    // than in `commit_add_torrent` (on the session actor) so the alert
7325    // ordering invariant survives spawn-per-add. `TorrentHandle::from_torrent`
7326    // spawns the `TorrentActor` internally but doesn't yield between
7327    // the spawn and its return, so this post races only with the
7328    // following `commit_tx.send.await` yield. The `TorrentActor`'s
7329    // first alert (`StateChanged → Checking` from `verify_existing_pieces`)
7330    // therefore fires AFTER this `TorrentAdded`.
7331    //
7332    // **Limitation**: parallel adds with the same info-hash both reach
7333    // here and both post `TorrentAdded`, even though the commit re-check
7334    // will fail one of them with `DuplicateTorrent`. The losing
7335    // `TorrentActor` shuts down cleanly when its `TorrentHandle` is
7336    // dropped in `commit_add_torrent`, but its `TorrentAdded` is a
7337    // false positive. Production callers do not parallelise same-hash
7338    // adds; accepted edge case.
7339    post_alert(
7340        &alert_tx,
7341        &alert_mask,
7342        AlertKind::TorrentAdded {
7343            info_hash,
7344            name: meta.info.name.clone(),
7345        },
7346    );
7347    Ok(PreparedAddTorrent {
7348        handle,
7349        info_hash,
7350        is_private,
7351        m170_post,
7352    })
7353}
7354
7355fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7356    use irontide_core::{FileEntry, InfoDict};
7357
7358    let info_hash = v2.info_hashes.best_v1();
7359
7360    // Build file entries from v2 file tree
7361    let v2_files = v2.info.files();
7362    let file_entries: Vec<FileEntry> = v2_files
7363        .iter()
7364        .map(|f| FileEntry {
7365            length: f.attr.length,
7366            path: f.path.clone(),
7367            attr: None,
7368            mtime: None,
7369            symlink_path: None,
7370        })
7371        .collect();
7372
7373    // v2-only torrents have no v1 piece hashes — use placeholder pieces field.
7374    // Verification is done via v2 Merkle trees, not v1 SHA-1 hashes.
7375    let num_pieces = v2.info.num_pieces() as usize;
7376    let pieces = vec![0u8; num_pieces * 20];
7377
7378    let info = InfoDict {
7379        name: v2.info.name.clone(),
7380        piece_length: v2.info.piece_length,
7381        pieces,
7382        length: if file_entries.len() == 1 {
7383            Some(file_entries[0].length)
7384        } else {
7385            None
7386        },
7387        files: if file_entries.len() > 1 {
7388            Some(file_entries)
7389        } else {
7390            None
7391        },
7392        private: None,
7393        source: None,
7394        ssl_cert: v2.ssl_cert.clone(),
7395        similar: Vec::new(),
7396        collections: Vec::new(),
7397    };
7398
7399    irontide_core::TorrentMetaV1 {
7400        info_hash,
7401        announce: v2.announce.clone(),
7402        announce_list: v2.announce_list.clone(),
7403        comment: v2.comment.clone(),
7404        created_by: v2.created_by.clone(),
7405        creation_date: v2.creation_date,
7406        info,
7407        info_bytes: None,
7408        url_list: Vec::new(),
7409        httpseeds: Vec::new(),
7410        ssl_cert: v2.ssl_cert.clone(),
7411    }
7412}
7413
7414#[cfg(test)]
7415mod tests {
7416    use super::*;
7417    use crate::types::TorrentState;
7418    use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
7419    use irontide_storage::MemoryStorage;
7420    use std::time::Duration;
7421
7422    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
7423        use serde::Serialize;
7424
7425        #[derive(Serialize)]
7426        struct Info<'a> {
7427            length: u64,
7428            name: &'a str,
7429            #[serde(rename = "piece length")]
7430            piece_length: u64,
7431            #[serde(with = "serde_bytes")]
7432            pieces: &'a [u8],
7433        }
7434
7435        #[derive(Serialize)]
7436        struct Torrent<'a> {
7437            info: Info<'a>,
7438        }
7439
7440        let mut pieces = Vec::new();
7441        let mut offset = 0;
7442        while offset < data.len() {
7443            let end = (offset + piece_length as usize).min(data.len());
7444            let hash = irontide_core::sha1(&data[offset..end]);
7445            pieces.extend_from_slice(hash.as_bytes());
7446            offset = end;
7447        }
7448
7449        let t = Torrent {
7450            info: Info {
7451                length: data.len() as u64,
7452                name: "test",
7453                piece_length,
7454                pieces: &pieces,
7455            },
7456        };
7457
7458        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7459        torrent_from_bytes(&bytes).unwrap()
7460    }
7461
7462    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
7463        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
7464        Arc::new(MemoryStorage::new(lengths))
7465    }
7466
7467    static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7468
7469    fn test_settings() -> Settings {
7470        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7471        let pid = std::process::id();
7472        let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
7473        let resume_dir =
7474            std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
7475        let _ = std::fs::remove_dir_all(&dl_dir);
7476        let _ = std::fs::remove_dir_all(&resume_dir);
7477        let _ = std::fs::create_dir_all(&dl_dir);
7478
7479        Settings {
7480            listen_port: 0,
7481            download_dir: dl_dir,
7482            resume_data_dir: Some(resume_dir),
7483            max_torrents: 10,
7484            enable_dht: false,
7485            enable_pex: false,
7486            enable_lsd: false,
7487            enable_fast_extension: false,
7488            enable_utp: false,
7489            enable_upnp: false,
7490            enable_natpmp: false,
7491            enable_ipv6: false,
7492            alert_channel_size: 64,
7493            disk_io_threads: 2,
7494            storage_mode: irontide_core::StorageMode::Sparse,
7495            disk_cache_size: 1024 * 1024,
7496            ..Settings::default()
7497        }
7498    }
7499
7500    // ---- Test 1: Start and shutdown ----
7501
7502    #[tokio::test]
7503    async fn session_start_and_shutdown() {
7504        let session = SessionHandle::start(test_settings()).await.unwrap();
7505        let stats = session.session_stats().await.unwrap();
7506        assert_eq!(stats.active_torrents, 0);
7507        session.shutdown().await.unwrap();
7508    }
7509
7510    #[tokio::test]
7511    async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
7512        let session = SessionHandle::start(test_settings()).await.unwrap();
7513        let bogus = Id20([0u8; 20]);
7514        let result = session.peer_unchoke_durations(bogus).await.unwrap();
7515        assert!(
7516            result.is_none(),
7517            "missing torrent must yield None, not an empty map"
7518        );
7519        session.shutdown().await.unwrap();
7520    }
7521
7522    #[tokio::test]
7523    async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
7524        let session = SessionHandle::start(test_settings()).await.unwrap();
7525        let data = vec![0xAB; 16384];
7526        let meta = make_test_torrent(&data, 16384);
7527        let storage = make_storage(&data, 16384);
7528        let info_hash = session
7529            .add_torrent_with_meta(meta.into(), Some(storage))
7530            .await
7531            .unwrap();
7532        let result = session
7533            .peer_unchoke_durations(info_hash)
7534            .await
7535            .unwrap()
7536            .expect("known torrent must yield Some, even with no peers");
7537        assert!(
7538            result.is_empty(),
7539            "fresh torrent with no peers has no unchoke history"
7540        );
7541        session.shutdown().await.unwrap();
7542    }
7543
7544    // ---- Test 2: Add and list torrent ----
7545
7546    #[tokio::test]
7547    async fn add_and_list_torrent() {
7548        let session = SessionHandle::start(test_settings()).await.unwrap();
7549        let data = vec![0xAB; 16384];
7550        let meta = make_test_torrent(&data, 16384);
7551        let expected_hash = meta.info_hash;
7552
7553        let storage = make_storage(&data, 16384);
7554        let info_hash = session
7555            .add_torrent_with_meta(meta.into(), Some(storage))
7556            .await
7557            .unwrap();
7558        assert_eq!(info_hash, expected_hash);
7559
7560        let list = session.list_torrents().await.unwrap();
7561        assert_eq!(list.len(), 1);
7562        assert!(list.contains(&info_hash));
7563
7564        session.shutdown().await.unwrap();
7565    }
7566
7567    // ---- Test 3: Remove torrent ----
7568
7569    #[tokio::test]
7570    async fn remove_torrent() {
7571        let session = SessionHandle::start(test_settings()).await.unwrap();
7572        let data = vec![0xAB; 16384];
7573        let meta = make_test_torrent(&data, 16384);
7574        let storage = make_storage(&data, 16384);
7575
7576        let info_hash = session
7577            .add_torrent_with_meta(meta.into(), Some(storage))
7578            .await
7579            .unwrap();
7580        session.remove_torrent(info_hash).await.unwrap();
7581
7582        tokio::time::sleep(Duration::from_millis(50)).await;
7583
7584        let list = session.list_torrents().await.unwrap();
7585        assert!(list.is_empty());
7586
7587        session.shutdown().await.unwrap();
7588    }
7589
7590    // ---- Test 4: Duplicate rejection ----
7591
7592    #[tokio::test]
7593    async fn duplicate_torrent_rejected() {
7594        let session = SessionHandle::start(test_settings()).await.unwrap();
7595        let data = vec![0xAB; 16384];
7596        let meta = make_test_torrent(&data, 16384);
7597        let storage1 = make_storage(&data, 16384);
7598        let storage2 = make_storage(&data, 16384);
7599
7600        session
7601            .add_torrent_with_meta(meta.clone().into(), Some(storage1))
7602            .await
7603            .unwrap();
7604        let result = session
7605            .add_torrent_with_meta(meta.into(), Some(storage2))
7606            .await;
7607        assert!(result.is_err());
7608        assert!(result.unwrap_err().to_string().contains("duplicate"));
7609
7610        session.shutdown().await.unwrap();
7611    }
7612
7613    // ---- Test 5: Max capacity ----
7614
7615    #[tokio::test]
7616    async fn session_at_capacity() {
7617        let mut config = test_settings();
7618        config.max_torrents = 1;
7619        let session = SessionHandle::start(config).await.unwrap();
7620
7621        let data1 = vec![0xAA; 16384];
7622        let meta1 = make_test_torrent(&data1, 16384);
7623        let storage1 = make_storage(&data1, 16384);
7624        session
7625            .add_torrent_with_meta(meta1.into(), Some(storage1))
7626            .await
7627            .unwrap();
7628
7629        let data2 = vec![0xBB; 16384];
7630        let meta2 = make_test_torrent(&data2, 16384);
7631        let storage2 = make_storage(&data2, 16384);
7632        let result = session
7633            .add_torrent_with_meta(meta2.into(), Some(storage2))
7634            .await;
7635        assert!(result.is_err());
7636        assert!(result.unwrap_err().to_string().contains("capacity"));
7637
7638        session.shutdown().await.unwrap();
7639    }
7640
7641    // ---- Test 6: Torrent stats ----
7642
7643    #[tokio::test]
7644    async fn torrent_stats_via_session() {
7645        let session = SessionHandle::start(test_settings()).await.unwrap();
7646        let data = vec![0xAB; 32768];
7647        let meta = make_test_torrent(&data, 16384);
7648        let storage = make_storage(&data, 16384);
7649
7650        let info_hash = session
7651            .add_torrent_with_meta(meta.into(), Some(storage))
7652            .await
7653            .unwrap();
7654        let stats = session.torrent_stats(info_hash).await.unwrap();
7655        assert_eq!(stats.state, TorrentState::Downloading);
7656        assert_eq!(stats.pieces_total, 2);
7657
7658        session.shutdown().await.unwrap();
7659    }
7660
7661    // ---- Test 7: Torrent info ----
7662
7663    #[tokio::test]
7664    async fn torrent_info_via_session() {
7665        let session = SessionHandle::start(test_settings()).await.unwrap();
7666        let data = vec![0xAB; 32768];
7667        let meta = make_test_torrent(&data, 16384);
7668        let storage = make_storage(&data, 16384);
7669
7670        let info_hash = session
7671            .add_torrent_with_meta(meta.into(), Some(storage))
7672            .await
7673            .unwrap();
7674        let info = session.torrent_info(info_hash).await.unwrap();
7675        assert_eq!(info.info_hash, info_hash);
7676        assert_eq!(info.name, "test");
7677        assert_eq!(info.total_length, 32768);
7678        assert_eq!(info.num_pieces, 2);
7679        assert!(!info.private);
7680        assert_eq!(info.files.len(), 1);
7681        assert_eq!(info.files[0].length, 32768);
7682
7683        session.shutdown().await.unwrap();
7684    }
7685
7686    // ---- Test 8: Pause/resume via session ----
7687
7688    #[tokio::test]
7689    async fn pause_resume_via_session() {
7690        let session = SessionHandle::start(test_settings()).await.unwrap();
7691        let data = vec![0xAB; 16384];
7692        let meta = make_test_torrent(&data, 16384);
7693        let storage = make_storage(&data, 16384);
7694
7695        let info_hash = session
7696            .add_torrent_with_meta(meta.into(), Some(storage))
7697            .await
7698            .unwrap();
7699
7700        session.pause_torrent(info_hash).await.unwrap();
7701        tokio::time::sleep(Duration::from_millis(50)).await;
7702        let stats = session.torrent_stats(info_hash).await.unwrap();
7703        assert_eq!(stats.state, TorrentState::Paused);
7704
7705        session.resume_torrent(info_hash).await.unwrap();
7706        tokio::time::sleep(Duration::from_millis(50)).await;
7707        let stats = session.torrent_stats(info_hash).await.unwrap();
7708        assert_eq!(stats.state, TorrentState::Downloading);
7709
7710        session.shutdown().await.unwrap();
7711    }
7712
7713    // ---- Test 9: Not-found errors ----
7714
7715    #[tokio::test]
7716    async fn not_found_errors() {
7717        let session = SessionHandle::start(test_settings()).await.unwrap();
7718        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7719
7720        assert!(session.torrent_stats(fake_hash).await.is_err());
7721        assert!(session.torrent_info(fake_hash).await.is_err());
7722        assert!(session.pause_torrent(fake_hash).await.is_err());
7723        assert!(session.resume_torrent(fake_hash).await.is_err());
7724        assert!(session.remove_torrent(fake_hash).await.is_err());
7725
7726        session.shutdown().await.unwrap();
7727    }
7728
7729    // ---- Test 10: Session stats ----
7730
7731    #[tokio::test]
7732    async fn session_stats_aggregate() {
7733        let session = SessionHandle::start(test_settings()).await.unwrap();
7734
7735        let data1 = vec![0xAA; 16384];
7736        let meta1 = make_test_torrent(&data1, 16384);
7737        let storage1 = make_storage(&data1, 16384);
7738        session
7739            .add_torrent_with_meta(meta1.into(), Some(storage1))
7740            .await
7741            .unwrap();
7742
7743        let data2 = vec![0xBB; 16384];
7744        let meta2 = make_test_torrent(&data2, 16384);
7745        let storage2 = make_storage(&data2, 16384);
7746        session
7747            .add_torrent_with_meta(meta2.into(), Some(storage2))
7748            .await
7749            .unwrap();
7750
7751        let stats = session.session_stats().await.unwrap();
7752        assert_eq!(stats.active_torrents, 2);
7753
7754        session.shutdown().await.unwrap();
7755    }
7756
7757    // ---- Test 11: Add magnet and list ----
7758
7759    #[tokio::test]
7760    async fn add_magnet_and_list() {
7761        use irontide_core::Magnet;
7762
7763        let session = SessionHandle::start(test_settings()).await.unwrap();
7764        let magnet = Magnet {
7765            info_hashes: irontide_core::InfoHashes::v1_only(
7766                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7767            ),
7768            display_name: Some("test-magnet".into()),
7769            trackers: vec![],
7770            peers: vec![],
7771            selected_files: None,
7772        };
7773        let expected_hash = magnet.info_hash();
7774
7775        let info_hash = session.add_magnet(magnet).await.unwrap();
7776        assert_eq!(info_hash, expected_hash);
7777
7778        let list = session.list_torrents().await.unwrap();
7779        assert_eq!(list.len(), 1);
7780        assert!(list.contains(&info_hash));
7781
7782        // torrent_info should fail with MetadataNotReady
7783        let err = session.torrent_info(info_hash).await.unwrap_err();
7784        assert!(err.to_string().contains("metadata not yet available"));
7785
7786        session.shutdown().await.unwrap();
7787    }
7788
7789    // ---- Test 12: Duplicate magnet rejected ----
7790
7791    #[tokio::test]
7792    async fn add_magnet_duplicate_rejected() {
7793        use irontide_core::Magnet;
7794
7795        let session = SessionHandle::start(test_settings()).await.unwrap();
7796        let magnet = Magnet {
7797            info_hashes: irontide_core::InfoHashes::v1_only(
7798                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7799            ),
7800            display_name: Some("test-magnet".into()),
7801            trackers: vec![],
7802            peers: vec![],
7803            selected_files: None,
7804        };
7805
7806        session.add_magnet(magnet.clone()).await.unwrap();
7807        let result = session.add_magnet(magnet).await;
7808        assert!(result.is_err());
7809        assert!(result.unwrap_err().to_string().contains("duplicate"));
7810
7811        session.shutdown().await.unwrap();
7812    }
7813
7814    // ---- Test 13: Session with LSD enabled ----
7815
7816    #[tokio::test]
7817    async fn session_with_lsd_enabled() {
7818        use irontide_core::Magnet;
7819
7820        // LSD may fail to bind port 6771 — session should still start
7821        let mut config = test_settings();
7822        config.enable_lsd = true;
7823
7824        let session = SessionHandle::start(config).await.unwrap();
7825
7826        // Add a torrent (triggers LSD announce if available)
7827        let data = vec![0xAB; 16384];
7828        let meta = make_test_torrent(&data, 16384);
7829        let storage = make_storage(&data, 16384);
7830        session
7831            .add_torrent_with_meta(meta.into(), Some(storage))
7832            .await
7833            .unwrap();
7834
7835        // Add a magnet (also triggers LSD announce)
7836        let magnet = Magnet {
7837            info_hashes: irontide_core::InfoHashes::v1_only(
7838                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
7839            ),
7840            display_name: Some("lsd-test".into()),
7841            trackers: vec![],
7842            peers: vec![],
7843            selected_files: None,
7844        };
7845        session.add_magnet(magnet).await.unwrap();
7846
7847        let list = session.list_torrents().await.unwrap();
7848        assert_eq!(list.len(), 2);
7849
7850        session.shutdown().await.unwrap();
7851    }
7852
7853    // ---- Test: v2-only torrent addition ----
7854
7855    #[tokio::test]
7856    async fn add_v2_only_torrent() {
7857        use irontide_bencode::BencodeValue;
7858        use std::collections::BTreeMap;
7859
7860        let session = SessionHandle::start(test_settings()).await.unwrap();
7861
7862        // Build a minimal v2-only torrent
7863        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7864        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
7865        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7866        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
7867        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7868        ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
7869
7870        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7871        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
7872        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
7873        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
7874        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
7875
7876        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7877        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
7878
7879        let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
7880        let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
7881        assert!(meta.is_v2());
7882
7883        // This should NOT return an error now (v2-only is supported)
7884        let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
7885        let list = session.list_torrents().await.unwrap();
7886        assert!(list.contains(&info_hash));
7887
7888        session.shutdown().await.unwrap();
7889    }
7890
7891    // ---- Test 14: Save torrent resume data via session ----
7892
7893    #[tokio::test]
7894    async fn save_torrent_resume_data_via_session() {
7895        let session = SessionHandle::start(test_settings()).await.unwrap();
7896        let data = vec![0xAB; 32768];
7897        let meta = make_test_torrent(&data, 16384);
7898        let info_hash = meta.info_hash;
7899        let storage = make_storage(&data, 16384);
7900        session
7901            .add_torrent_with_meta(meta.into(), Some(storage))
7902            .await
7903            .unwrap();
7904
7905        let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
7906        assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
7907        assert_eq!(rd.name, "test");
7908        assert_eq!(rd.file_format, "libtorrent resume file");
7909        assert_eq!(rd.file_version, 1);
7910        assert!(!rd.pieces.is_empty());
7911        assert_eq!(rd.paused, 0);
7912
7913        session.shutdown().await.unwrap();
7914    }
7915
7916    // ---- Test 15: Save session state captures all torrents ----
7917
7918    #[tokio::test]
7919    async fn save_session_state_captures_all_torrents() {
7920        let session = SessionHandle::start(test_settings()).await.unwrap();
7921
7922        let data1 = vec![0xAA; 16384];
7923        let meta1 = make_test_torrent(&data1, 16384);
7924        let storage1 = make_storage(&data1, 16384);
7925        session
7926            .add_torrent_with_meta(meta1.into(), Some(storage1))
7927            .await
7928            .unwrap();
7929
7930        let data2 = vec![0xBB; 16384];
7931        let meta2 = make_test_torrent(&data2, 16384);
7932        let storage2 = make_storage(&data2, 16384);
7933        session
7934            .add_torrent_with_meta(meta2.into(), Some(storage2))
7935            .await
7936            .unwrap();
7937
7938        let state = session.save_session_state().await.unwrap();
7939        assert_eq!(state.torrents.len(), 2);
7940
7941        for rd in &state.torrents {
7942            assert_eq!(rd.file_format, "libtorrent resume file");
7943            assert_eq!(rd.info_hash.len(), 20);
7944        }
7945
7946        session.shutdown().await.unwrap();
7947    }
7948
7949    // ---- Test 16: Save resume data not found ----
7950
7951    #[tokio::test]
7952    async fn save_resume_data_not_found() {
7953        let session = SessionHandle::start(test_settings()).await.unwrap();
7954        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7955        let result = session.save_torrent_resume_data(fake_hash).await;
7956        assert!(result.is_err());
7957        assert!(result.unwrap_err().to_string().contains("not found"));
7958        session.shutdown().await.unwrap();
7959    }
7960
7961    // ---- Test 17: Subscribe receives TorrentAdded alert ----
7962
7963    #[tokio::test]
7964    async fn subscribe_receives_torrent_added_alert() {
7965        use crate::alert::AlertKind;
7966
7967        let session = SessionHandle::start(test_settings()).await.unwrap();
7968        let mut alerts = session.subscribe();
7969
7970        let data = vec![0xAB; 16384];
7971        let meta = make_test_torrent(&data, 16384);
7972        let storage = make_storage(&data, 16384);
7973        let _info_hash = session
7974            .add_torrent_with_meta(meta.into(), Some(storage))
7975            .await
7976            .unwrap();
7977
7978        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
7979            .await
7980            .unwrap()
7981            .unwrap();
7982        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
7983        session.shutdown().await.unwrap();
7984    }
7985
7986    // ---- Test 18: Subscribe receives TorrentRemoved alert ----
7987
7988    #[tokio::test]
7989    async fn subscribe_receives_torrent_removed_alert() {
7990        use crate::alert::AlertKind;
7991        use crate::types::TorrentState;
7992
7993        let session = SessionHandle::start(test_settings()).await.unwrap();
7994        let mut alerts = session.subscribe();
7995
7996        let data = vec![0xAB; 16384];
7997        let meta = make_test_torrent(&data, 16384);
7998        let storage = make_storage(&data, 16384);
7999        let info_hash = session
8000            .add_torrent_with_meta(meta.into(), Some(storage))
8001            .await
8002            .unwrap();
8003
8004        // Drain TorrentAdded and any checking alerts
8005        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8006            if matches!(
8007                a.kind,
8008                AlertKind::StateChanged {
8009                    new_state: TorrentState::Downloading,
8010                    ..
8011                }
8012            ) {
8013                break;
8014            }
8015        }
8016
8017        session.remove_torrent(info_hash).await.unwrap();
8018
8019        // Find TorrentRemoved (skip any interleaved alerts)
8020        loop {
8021            let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8022                .await
8023                .unwrap()
8024                .unwrap();
8025            if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8026                break;
8027            }
8028        }
8029        session.shutdown().await.unwrap();
8030    }
8031
8032    // ---- Test 19: Multiple subscribers each receive alerts ----
8033
8034    #[tokio::test]
8035    async fn multiple_subscribers_each_receive_alerts() {
8036        use crate::alert::AlertKind;
8037
8038        let session = SessionHandle::start(test_settings()).await.unwrap();
8039        let mut sub1 = session.subscribe();
8040        let mut sub2 = session.subscribe();
8041
8042        let data = vec![0xAB; 16384];
8043        let meta = make_test_torrent(&data, 16384);
8044        let storage = make_storage(&data, 16384);
8045        session
8046            .add_torrent_with_meta(meta.into(), Some(storage))
8047            .await
8048            .unwrap();
8049
8050        let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8051            .await
8052            .unwrap()
8053            .unwrap();
8054        let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8055            .await
8056            .unwrap()
8057            .unwrap();
8058
8059        assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8060        assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8061        session.shutdown().await.unwrap();
8062    }
8063
8064    // ---- Test 20: set_alert_mask filters at runtime ----
8065
8066    #[tokio::test]
8067    async fn set_alert_mask_filters_at_runtime() {
8068        use crate::alert::{AlertCategory, AlertKind};
8069
8070        let session = SessionHandle::start(test_settings()).await.unwrap();
8071        let mut alerts = session.subscribe();
8072
8073        // Start with ALL — TorrentAdded (STATUS) should arrive
8074        let data = vec![0xAB; 16384];
8075        let meta = make_test_torrent(&data, 16384);
8076        let storage = make_storage(&data, 16384);
8077        session
8078            .add_torrent_with_meta(meta.into(), Some(storage))
8079            .await
8080            .unwrap();
8081
8082        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8083            .await
8084            .unwrap()
8085            .unwrap();
8086        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8087
8088        // Drain any remaining alerts from the first torrent (StateChanged, CheckingProgress, etc.)
8089        while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8090            .await
8091            .is_ok()
8092        {}
8093
8094        // Change mask to empty — no alerts should pass
8095        session.set_alert_mask(AlertCategory::empty());
8096
8097        let data2 = vec![0xBB; 16384];
8098        let meta2 = make_test_torrent(&data2, 16384);
8099        let storage2 = make_storage(&data2, 16384);
8100        session
8101            .add_torrent_with_meta(meta2.into(), Some(storage2))
8102            .await
8103            .unwrap();
8104
8105        // Give a small window — nothing should arrive
8106        let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8107        assert!(result.is_err(), "should have timed out with empty mask");
8108
8109        // Restore STATUS — adding another torrent should arrive
8110        session.set_alert_mask(AlertCategory::STATUS);
8111
8112        let data3 = vec![0xCC; 16384];
8113        let meta3 = make_test_torrent(&data3, 16384);
8114        let storage3 = make_storage(&data3, 16384);
8115        session
8116            .add_torrent_with_meta(meta3.into(), Some(storage3))
8117            .await
8118            .unwrap();
8119
8120        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8121            .await
8122            .unwrap()
8123            .unwrap();
8124        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8125
8126        session.shutdown().await.unwrap();
8127    }
8128
8129    // ---- Test 21: AlertStream filters per subscriber ----
8130
8131    #[tokio::test]
8132    async fn alert_stream_filters_per_subscriber() {
8133        use crate::alert::{AlertCategory, AlertKind};
8134
8135        let session = SessionHandle::start(test_settings()).await.unwrap();
8136
8137        // subscriber A: STATUS only
8138        let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8139        // subscriber B: PEER only
8140        let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8141
8142        let data = vec![0xAB; 16384];
8143        let meta = make_test_torrent(&data, 16384);
8144        let storage = make_storage(&data, 16384);
8145        session
8146            .add_torrent_with_meta(meta.into(), Some(storage))
8147            .await
8148            .unwrap();
8149
8150        // STATUS sub gets TorrentAdded
8151        let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8152            .await
8153            .unwrap()
8154            .unwrap();
8155        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8156
8157        // PEER sub should NOT receive TorrentAdded (it's STATUS category)
8158        let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8159        assert!(
8160            result.is_err(),
8161            "PEER subscriber should not get STATUS alerts"
8162        );
8163
8164        session.shutdown().await.unwrap();
8165    }
8166
8167    // ---- Test 22: State changed tracks transitions ----
8168
8169    #[tokio::test]
8170    async fn state_changed_tracks_transitions() {
8171        use crate::alert::AlertKind;
8172
8173        let session = SessionHandle::start(test_settings()).await.unwrap();
8174        let mut alerts = session.subscribe();
8175
8176        let data = vec![0xAB; 16384];
8177        let meta = make_test_torrent(&data, 16384);
8178        let storage = make_storage(&data, 16384);
8179        let info_hash = session
8180            .add_torrent_with_meta(meta.into(), Some(storage))
8181            .await
8182            .unwrap();
8183
8184        // Drain TorrentAdded
8185        let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8186            .await
8187            .unwrap();
8188
8189        // Pause — should get StateChanged(Downloading → Paused) + TorrentPaused
8190        session.pause_torrent(info_hash).await.unwrap();
8191        tokio::time::sleep(Duration::from_millis(100)).await;
8192
8193        // Collect alerts over a short window
8194        let mut state_changes = Vec::new();
8195        let mut paused_alerts = Vec::new();
8196        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8197        {
8198            match &a.kind {
8199                AlertKind::StateChanged {
8200                    prev_state,
8201                    new_state,
8202                    ..
8203                } => {
8204                    state_changes.push((*prev_state, *new_state));
8205                }
8206                AlertKind::TorrentPaused { .. } => {
8207                    paused_alerts.push(a);
8208                }
8209                _ => {} // other alerts (PeerConnected etc)
8210            }
8211        }
8212
8213        assert!(
8214            state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8215            "expected Downloading→Paused, got: {state_changes:?}"
8216        );
8217        assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8218
8219        // Resume — should get StateChanged(Paused → Downloading) + TorrentResumed
8220        session.resume_torrent(info_hash).await.unwrap();
8221        tokio::time::sleep(Duration::from_millis(100)).await;
8222
8223        let mut resume_state_changes = Vec::new();
8224        let mut resumed_alerts = Vec::new();
8225        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8226        {
8227            match &a.kind {
8228                AlertKind::StateChanged {
8229                    prev_state,
8230                    new_state,
8231                    ..
8232                } => {
8233                    resume_state_changes.push((*prev_state, *new_state));
8234                }
8235                AlertKind::TorrentResumed { .. } => {
8236                    resumed_alerts.push(a);
8237                }
8238                _ => {}
8239            }
8240        }
8241
8242        assert!(
8243            resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8244            "expected Paused→Downloading, got: {resume_state_changes:?}"
8245        );
8246        assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8247
8248        session.shutdown().await.unwrap();
8249    }
8250
8251    #[tokio::test]
8252    async fn session_config_creates_utp_socket() {
8253        // Start session with uTP enabled — should succeed without errors
8254        let mut config = test_settings();
8255        config.enable_utp = true;
8256        let session = SessionHandle::start(config).await.unwrap();
8257        let stats = session.session_stats().await.unwrap();
8258        assert_eq!(stats.active_torrents, 0);
8259        session.shutdown().await.unwrap();
8260    }
8261
8262    #[test]
8263    fn settings_nat_defaults() {
8264        let s = Settings::default();
8265        assert!(s.enable_upnp, "enable_upnp should default to true");
8266        assert!(s.enable_natpmp, "enable_natpmp should default to true");
8267    }
8268
8269    #[tokio::test]
8270    async fn session_with_nat_disabled() {
8271        let config = test_settings();
8272        // test_session_config already sets enable_upnp: false, enable_natpmp: false
8273        assert!(!config.enable_upnp);
8274        assert!(!config.enable_natpmp);
8275        let session = SessionHandle::start(config).await.unwrap();
8276        let stats = session.session_stats().await.unwrap();
8277        assert_eq!(stats.active_torrents, 0);
8278        session.shutdown().await.unwrap();
8279    }
8280
8281    // ---- M29: Anonymous mode, force proxy, proxy config tests ----
8282
8283    #[test]
8284    fn anonymous_mode_disables_discovery() {
8285        let mut config = test_settings();
8286        config.anonymous_mode = true;
8287        config.enable_dht = true;
8288        config.enable_lsd = true;
8289        config.enable_upnp = true;
8290        config.enable_natpmp = true;
8291
8292        // SessionHandle::start() will override these when anonymous_mode is true.
8293        // We test the enforcement logic directly here.
8294        if config.anonymous_mode {
8295            config.enable_dht = false;
8296            config.enable_lsd = false;
8297            config.enable_upnp = false;
8298            config.enable_natpmp = false;
8299        }
8300
8301        assert!(!config.enable_dht);
8302        assert!(!config.enable_lsd);
8303        assert!(!config.enable_upnp);
8304        assert!(!config.enable_natpmp);
8305    }
8306
8307    #[tokio::test]
8308    async fn anonymous_mode_session_starts_with_discovery_disabled() {
8309        let mut config = test_settings();
8310        config.anonymous_mode = true;
8311        // Even if we enable these, anonymous_mode should override
8312        config.enable_dht = true;
8313        config.enable_lsd = true;
8314
8315        let session = SessionHandle::start(config).await.unwrap();
8316        let stats = session.session_stats().await.unwrap();
8317        assert_eq!(stats.active_torrents, 0);
8318        session.shutdown().await.unwrap();
8319    }
8320
8321    #[test]
8322    fn force_proxy_requires_proxy_configured() {
8323        let mut config = test_settings();
8324        config.force_proxy = true;
8325        config.proxy = crate::proxy::ProxyConfig::default(); // no proxy
8326
8327        // Validate the config error
8328        assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8329        assert!(config.force_proxy);
8330        // This would error in SessionHandle::start()
8331    }
8332
8333    #[tokio::test]
8334    async fn force_proxy_errors_without_proxy() {
8335        let mut config = test_settings();
8336        config.force_proxy = true;
8337        // proxy_type is None by default
8338
8339        let result = SessionHandle::start(config).await;
8340        assert!(result.is_err());
8341        match result {
8342            Err(e) => assert!(
8343                e.to_string().contains("force_proxy"),
8344                "error should mention force_proxy: {e}"
8345            ),
8346            Ok(_) => panic!("expected error"),
8347        }
8348    }
8349
8350    #[test]
8351    fn force_proxy_disables_features() {
8352        let mut config = test_settings();
8353        config.force_proxy = true;
8354        config.proxy = crate::proxy::ProxyConfig {
8355            proxy_type: crate::proxy::ProxyType::Socks5,
8356            hostname: "proxy.example.com".into(),
8357            port: 1080,
8358            ..Default::default()
8359        };
8360        config.enable_dht = true;
8361        config.enable_lsd = true;
8362        config.enable_upnp = true;
8363        config.enable_natpmp = true;
8364
8365        // Simulate the enforcement from start()
8366        if config.force_proxy {
8367            config.enable_upnp = false;
8368            config.enable_natpmp = false;
8369            config.enable_dht = false;
8370            config.enable_lsd = false;
8371        }
8372
8373        assert!(!config.enable_dht);
8374        assert!(!config.enable_lsd);
8375        assert!(!config.enable_upnp);
8376        assert!(!config.enable_natpmp);
8377    }
8378
8379    #[test]
8380    fn proxy_config_round_trip() {
8381        let s = Settings {
8382            proxy: crate::proxy::ProxyConfig {
8383                proxy_type: crate::proxy::ProxyType::Socks5Password,
8384                hostname: "localhost".into(),
8385                port: 9050,
8386                username: Some("user".into()),
8387                password: Some("pass".into()),
8388                ..Default::default()
8389            },
8390            force_proxy: true,
8391            anonymous_mode: true,
8392            ..test_settings()
8393        };
8394
8395        assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
8396        assert_eq!(s.proxy.hostname, "localhost");
8397        assert_eq!(s.proxy.port, 9050);
8398        assert!(s.force_proxy);
8399        assert!(s.anonymous_mode);
8400        assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
8401    }
8402
8403    #[tokio::test]
8404    async fn apply_settings_runtime() {
8405        let session = SessionHandle::start(test_settings()).await.unwrap();
8406        let original = session.settings().await.unwrap();
8407        assert_eq!(original.max_torrents, 10);
8408
8409        let mut new = original.clone();
8410        new.max_torrents = 200;
8411        new.upload_rate_limit = 1_000_000;
8412        session.apply_settings(new).await.unwrap();
8413
8414        let updated = session.settings().await.unwrap();
8415        assert_eq!(updated.max_torrents, 200);
8416        assert_eq!(updated.upload_rate_limit, 1_000_000);
8417
8418        session.shutdown().await.unwrap();
8419    }
8420
8421    #[tokio::test]
8422    async fn apply_settings_validation_error() {
8423        let session = SessionHandle::start(test_settings()).await.unwrap();
8424
8425        // force_proxy=true without a proxy configured should fail validation
8426        let bad = Settings {
8427            force_proxy: true,
8428            ..Settings::default()
8429        };
8430        let result = session.apply_settings(bad).await;
8431        assert!(result.is_err());
8432
8433        // Original settings should be unchanged
8434        let current = session.settings().await.unwrap();
8435        assert!(!current.force_proxy);
8436
8437        session.shutdown().await.unwrap();
8438    }
8439
8440    // ---- M50: Session stats counters tests ----
8441
8442    #[tokio::test]
8443    async fn session_stats_counters_accessible() {
8444        let session = SessionHandle::start(test_settings()).await.unwrap();
8445        let counters = session.counters();
8446        // Exercise the uptime accessor (returns u64, so >= 0 is tautological;
8447        // the meaningful check is that the call doesn't panic and counters
8448        // are wired up).
8449        let _ = counters.uptime_secs();
8450        assert_eq!(counters.len(), crate::stats::NUM_METRICS);
8451        session.shutdown().await.unwrap();
8452    }
8453
8454    #[tokio::test]
8455    async fn post_session_stats_fires_alert() {
8456        use crate::alert::{AlertCategory, AlertKind};
8457
8458        let session = SessionHandle::start(test_settings()).await.unwrap();
8459        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8460
8461        session.post_session_stats().await.unwrap();
8462
8463        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8464            .await
8465            .expect("timed out waiting for SessionStatsAlert")
8466            .expect("recv error");
8467        assert!(
8468            matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
8469            "expected SessionStatsAlert with {} values, got {:?}",
8470            crate::stats::NUM_METRICS,
8471            alert.kind,
8472        );
8473        session.shutdown().await.unwrap();
8474    }
8475
8476    #[tokio::test]
8477    async fn session_stats_include_torrent_count() {
8478        use crate::alert::{AlertCategory, AlertKind};
8479
8480        let session = SessionHandle::start(test_settings()).await.unwrap();
8481        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8482
8483        // Add a torrent
8484        let data = vec![0xAB; 16384];
8485        let meta = make_test_torrent(&data, 16384);
8486        let storage = make_storage(&data, 16384);
8487        session
8488            .add_torrent_with_meta(meta.into(), Some(storage))
8489            .await
8490            .unwrap();
8491
8492        session.post_session_stats().await.unwrap();
8493
8494        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8495            .await
8496            .expect("timed out waiting for SessionStatsAlert")
8497            .expect("recv error");
8498        match alert.kind {
8499            AlertKind::SessionStatsAlert { values } => {
8500                assert!(
8501                    values[crate::stats::SES_NUM_TORRENTS] > 0,
8502                    "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
8503                    values[crate::stats::SES_NUM_TORRENTS],
8504                );
8505            }
8506            other => panic!("expected SessionStatsAlert, got {other:?}"),
8507        }
8508        session.shutdown().await.unwrap();
8509    }
8510
8511    #[tokio::test]
8512    async fn stats_timer_disabled_when_zero() {
8513        use crate::alert::AlertCategory;
8514
8515        let mut config = test_settings();
8516        config.stats_report_interval = 0;
8517        let session = SessionHandle::start(config).await.unwrap();
8518        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8519
8520        // Wait 200ms — no periodic stats alert should arrive
8521        let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
8522        assert!(
8523            result.is_err(),
8524            "no SessionStatsAlert should fire when stats_report_interval is 0"
8525        );
8526        session.shutdown().await.unwrap();
8527    }
8528
8529    #[tokio::test]
8530    async fn sample_infohashes_timer_disabled_when_zero() {
8531        use crate::alert::AlertCategory;
8532
8533        let mut config = test_settings();
8534        config.dht_sample_infohashes_interval = 0;
8535        let session = SessionHandle::start(config).await.unwrap();
8536        let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
8537
8538        // Wait 200ms — no DhtSampleInfohashes alert should arrive
8539        let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
8540        assert!(
8541            result.is_err(),
8542            "no DhtSampleInfohashes alert should fire when interval is 0"
8543        );
8544        session.shutdown().await.unwrap();
8545    }
8546
8547    // ---- Test: open_file returns TorrentNotFound for unknown hash ----
8548
8549    #[tokio::test]
8550    async fn open_file_not_found() {
8551        let session = SessionHandle::start(test_settings()).await.unwrap();
8552        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8553        let result = session.open_file(fake_hash, 0).await;
8554        assert!(result.is_err());
8555        let err = result.err().unwrap();
8556        assert!(err.to_string().contains("not found"));
8557        session.shutdown().await.unwrap();
8558    }
8559
8560    // ---- Test: open_file on a real torrent routes to TorrentHandle ----
8561
8562    #[tokio::test]
8563    async fn open_file_routes_to_torrent() {
8564        let session = SessionHandle::start(test_settings()).await.unwrap();
8565        let data = vec![0xAB; 32768];
8566        let meta = make_test_torrent(&data, 16384);
8567        let storage = make_storage(&data, 16384);
8568
8569        let info_hash = session
8570            .add_torrent_with_meta(meta.into(), Some(storage))
8571            .await
8572            .unwrap();
8573
8574        // open_file should succeed for file_index 0 (single-file torrent)
8575        let stream = session.open_file(info_hash, 0).await;
8576        assert!(stream.is_ok(), "open_file should succeed for file_index 0");
8577
8578        // open_file should fail for out-of-range file_index
8579        let result = session.open_file(info_hash, 999).await;
8580        assert!(
8581            result.is_err(),
8582            "open_file should fail for invalid file_index"
8583        );
8584
8585        session.shutdown().await.unwrap();
8586    }
8587
8588    // ---- Test: force_reannounce via session ----
8589
8590    #[tokio::test]
8591    async fn session_force_reannounce() {
8592        let session = SessionHandle::start(test_settings()).await.unwrap();
8593        let data = vec![0xAB; 16384];
8594        let meta = make_test_torrent(&data, 16384);
8595        let storage = make_storage(&data, 16384);
8596        let info_hash = session
8597            .add_torrent_with_meta(meta.into(), Some(storage))
8598            .await
8599            .unwrap();
8600
8601        // Should succeed for a known torrent.
8602        let result = session.force_reannounce(info_hash).await;
8603        assert!(
8604            result.is_ok(),
8605            "force_reannounce should succeed: {result:?}"
8606        );
8607
8608        // Should fail for unknown torrent.
8609        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8610        assert!(session.force_reannounce(fake).await.is_err());
8611
8612        session.shutdown().await.unwrap();
8613    }
8614
8615    // ---- Test: tracker_list via session ----
8616
8617    #[tokio::test]
8618    async fn session_tracker_list() {
8619        let session = SessionHandle::start(test_settings()).await.unwrap();
8620        let data = vec![0xAB; 16384];
8621        let meta = make_test_torrent(&data, 16384);
8622        let storage = make_storage(&data, 16384);
8623        let info_hash = session
8624            .add_torrent_with_meta(meta.into(), Some(storage))
8625            .await
8626            .unwrap();
8627
8628        // Should succeed (empty list since test torrent has no announce URL).
8629        let trackers = session.tracker_list(info_hash).await.unwrap();
8630        assert!(trackers.is_empty(), "test torrent has no trackers");
8631
8632        // Should fail for unknown torrent.
8633        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8634        assert!(session.tracker_list(fake).await.is_err());
8635
8636        session.shutdown().await.unwrap();
8637    }
8638
8639    // ---- Test: scrape via session ----
8640
8641    #[tokio::test]
8642    async fn session_scrape() {
8643        let session = SessionHandle::start(test_settings()).await.unwrap();
8644        let data = vec![0xAB; 16384];
8645        let meta = make_test_torrent(&data, 16384);
8646        let storage = make_storage(&data, 16384);
8647        let info_hash = session
8648            .add_torrent_with_meta(meta.into(), Some(storage))
8649            .await
8650            .unwrap();
8651
8652        // Should succeed (None since test torrent has no trackers to scrape).
8653        let scrape = session.scrape(info_hash).await.unwrap();
8654        assert!(scrape.is_none(), "test torrent has no trackers to scrape");
8655
8656        // Should fail for unknown torrent.
8657        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8658        assert!(session.scrape(fake).await.is_err());
8659
8660        session.shutdown().await.unwrap();
8661    }
8662
8663    // ---- Test: set_file_priority via session ----
8664
8665    #[tokio::test]
8666    async fn session_set_file_priority() {
8667        let session = SessionHandle::start(test_settings()).await.unwrap();
8668        let data = vec![0xAB; 16384];
8669        let meta = make_test_torrent(&data, 16384);
8670        let storage = make_storage(&data, 16384);
8671        let info_hash = session
8672            .add_torrent_with_meta(meta.into(), Some(storage))
8673            .await
8674            .unwrap();
8675
8676        // Should succeed for file index 0 (single-file torrent).
8677        let result = session
8678            .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
8679            .await;
8680        assert!(
8681            result.is_ok(),
8682            "set_file_priority should succeed: {result:?}"
8683        );
8684
8685        // Should fail for unknown torrent.
8686        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8687        assert!(
8688            session
8689                .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
8690                .await
8691                .is_err()
8692        );
8693
8694        session.shutdown().await.unwrap();
8695    }
8696
8697    // ---- Test: file_priorities via session ----
8698
8699    #[tokio::test]
8700    async fn session_file_priorities() {
8701        let session = SessionHandle::start(test_settings()).await.unwrap();
8702        let data = vec![0xAB; 16384];
8703        let meta = make_test_torrent(&data, 16384);
8704        let storage = make_storage(&data, 16384);
8705        let info_hash = session
8706            .add_torrent_with_meta(meta.into(), Some(storage))
8707            .await
8708            .unwrap();
8709
8710        // Should return priorities for the single file.
8711        let priorities = session.file_priorities(info_hash).await.unwrap();
8712        assert_eq!(
8713            priorities.len(),
8714            1,
8715            "single-file torrent should have 1 file priority"
8716        );
8717        assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
8718
8719        // Should fail for unknown torrent.
8720        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8721        assert!(session.file_priorities(fake).await.is_err());
8722
8723        session.shutdown().await.unwrap();
8724    }
8725
8726    // ---- Test: set_download_limit zero means unlimited ----
8727
8728    #[tokio::test]
8729    async fn set_download_limit_zero_means_unlimited() {
8730        let session = SessionHandle::start(test_settings()).await.unwrap();
8731        let data = vec![0xAB; 16384];
8732        let meta = make_test_torrent(&data, 16384);
8733        let storage = make_storage(&data, 16384);
8734        let info_hash = session
8735            .add_torrent_with_meta(meta.into(), Some(storage))
8736            .await
8737            .unwrap();
8738
8739        // Set limit to non-zero, then back to zero (unlimited).
8740        session.set_download_limit(info_hash, 50_000).await.unwrap();
8741        session.set_download_limit(info_hash, 0).await.unwrap();
8742        let limit = session.download_limit(info_hash).await.unwrap();
8743        assert_eq!(limit, 0, "0 means unlimited");
8744
8745        session.shutdown().await.unwrap();
8746    }
8747
8748    // ---- Test: set_upload_limit persists ----
8749
8750    #[tokio::test]
8751    async fn set_upload_limit_persists() {
8752        let session = SessionHandle::start(test_settings()).await.unwrap();
8753        let data = vec![0xAB; 16384];
8754        let meta = make_test_torrent(&data, 16384);
8755        let storage = make_storage(&data, 16384);
8756        let info_hash = session
8757            .add_torrent_with_meta(meta.into(), Some(storage))
8758            .await
8759            .unwrap();
8760
8761        session.set_upload_limit(info_hash, 100_000).await.unwrap();
8762        let limit = session.upload_limit(info_hash).await.unwrap();
8763        assert_eq!(limit, 100_000);
8764
8765        session.shutdown().await.unwrap();
8766    }
8767
8768    // ---- Test: download_limit default is zero ----
8769
8770    #[tokio::test]
8771    async fn download_limit_default_is_zero() {
8772        let session = SessionHandle::start(test_settings()).await.unwrap();
8773        let data = vec![0xAB; 16384];
8774        let meta = make_test_torrent(&data, 16384);
8775        let storage = make_storage(&data, 16384);
8776        let info_hash = session
8777            .add_torrent_with_meta(meta.into(), Some(storage))
8778            .await
8779            .unwrap();
8780
8781        // Default config has download_rate_limit = 0.
8782        let limit = session.download_limit(info_hash).await.unwrap();
8783        assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
8784
8785        session.shutdown().await.unwrap();
8786    }
8787
8788    // ---- Test: rate_limit_round_trip ----
8789
8790    #[tokio::test]
8791    async fn rate_limit_round_trip() {
8792        let session = SessionHandle::start(test_settings()).await.unwrap();
8793        let data = vec![0xAB; 16384];
8794        let meta = make_test_torrent(&data, 16384);
8795        let storage = make_storage(&data, 16384);
8796        let info_hash = session
8797            .add_torrent_with_meta(meta.into(), Some(storage))
8798            .await
8799            .unwrap();
8800
8801        // Set both limits.
8802        session
8803            .set_download_limit(info_hash, 1_000_000)
8804            .await
8805            .unwrap();
8806        session.set_upload_limit(info_hash, 500_000).await.unwrap();
8807
8808        // Read them back.
8809        let dl = session.download_limit(info_hash).await.unwrap();
8810        let ul = session.upload_limit(info_hash).await.unwrap();
8811        assert_eq!(dl, 1_000_000);
8812        assert_eq!(ul, 500_000);
8813
8814        // Update and verify again.
8815        session
8816            .set_download_limit(info_hash, 2_000_000)
8817            .await
8818            .unwrap();
8819        let dl = session.download_limit(info_hash).await.unwrap();
8820        assert_eq!(dl, 2_000_000);
8821
8822        // Unknown torrent should fail.
8823        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8824        assert!(session.download_limit(fake).await.is_err());
8825        assert!(session.upload_limit(fake).await.is_err());
8826        assert!(session.set_download_limit(fake, 100).await.is_err());
8827        assert!(session.set_upload_limit(fake, 100).await.is_err());
8828
8829        session.shutdown().await.unwrap();
8830    }
8831
8832    // ---- Test: sequential_download_toggle ----
8833
8834    #[tokio::test]
8835    async fn sequential_download_toggle() {
8836        let session = SessionHandle::start(test_settings()).await.unwrap();
8837        let data = vec![0xAB; 16384];
8838        let meta = make_test_torrent(&data, 16384);
8839        let storage = make_storage(&data, 16384);
8840        let info_hash = session
8841            .add_torrent_with_meta(meta.into(), Some(storage))
8842            .await
8843            .unwrap();
8844
8845        // Enable sequential download.
8846        session
8847            .set_sequential_download(info_hash, true)
8848            .await
8849            .unwrap();
8850        assert!(session.is_sequential_download(info_hash).await.unwrap());
8851
8852        // Disable it again.
8853        session
8854            .set_sequential_download(info_hash, false)
8855            .await
8856            .unwrap();
8857        assert!(!session.is_sequential_download(info_hash).await.unwrap());
8858
8859        session.shutdown().await.unwrap();
8860    }
8861
8862    // ---- Test: super_seeding_toggle ----
8863
8864    #[tokio::test]
8865    async fn super_seeding_toggle() {
8866        let session = SessionHandle::start(test_settings()).await.unwrap();
8867        let data = vec![0xAB; 16384];
8868        let meta = make_test_torrent(&data, 16384);
8869        let storage = make_storage(&data, 16384);
8870        let info_hash = session
8871            .add_torrent_with_meta(meta.into(), Some(storage))
8872            .await
8873            .unwrap();
8874
8875        // Enable super seeding.
8876        session.set_super_seeding(info_hash, true).await.unwrap();
8877        assert!(session.is_super_seeding(info_hash).await.unwrap());
8878
8879        // Disable it again.
8880        session.set_super_seeding(info_hash, false).await.unwrap();
8881        assert!(!session.is_super_seeding(info_hash).await.unwrap());
8882
8883        session.shutdown().await.unwrap();
8884    }
8885
8886    // ---- Test: sequential_download_default_false ----
8887
8888    #[tokio::test]
8889    async fn sequential_download_default_false() {
8890        let session = SessionHandle::start(test_settings()).await.unwrap();
8891        let data = vec![0xAB; 16384];
8892        let meta = make_test_torrent(&data, 16384);
8893        let storage = make_storage(&data, 16384);
8894        let info_hash = session
8895            .add_torrent_with_meta(meta.into(), Some(storage))
8896            .await
8897            .unwrap();
8898
8899        // Default config has sequential_download = false.
8900        assert!(!session.is_sequential_download(info_hash).await.unwrap());
8901
8902        session.shutdown().await.unwrap();
8903    }
8904
8905    // ---- Test: super_seeding_default_false ----
8906
8907    #[tokio::test]
8908    async fn super_seeding_default_false() {
8909        let session = SessionHandle::start(test_settings()).await.unwrap();
8910        let data = vec![0xAB; 16384];
8911        let meta = make_test_torrent(&data, 16384);
8912        let storage = make_storage(&data, 16384);
8913        let info_hash = session
8914            .add_torrent_with_meta(meta.into(), Some(storage))
8915            .await
8916            .unwrap();
8917
8918        // Default config has super_seeding = false.
8919        assert!(!session.is_super_seeding(info_hash).await.unwrap());
8920
8921        session.shutdown().await.unwrap();
8922    }
8923
8924    // ---- M159 Test: seed_mode_flips_user_flag ----
8925
8926    #[tokio::test]
8927    async fn seed_mode_flips_user_flag() {
8928        let session = SessionHandle::start(test_settings()).await.unwrap();
8929        let data = vec![0xAB; 16384];
8930        let meta = make_test_torrent(&data, 16384);
8931        let storage = make_storage(&data, 16384);
8932        let info_hash = session
8933            .add_torrent_with_meta(meta.into(), Some(storage))
8934            .await
8935            .unwrap();
8936
8937        // Initial state: user_seed_mode defaults to false.
8938        let stats_before = session.torrent_stats(info_hash).await.unwrap();
8939        assert!(
8940            !stats_before.user_seed_mode,
8941            "new torrent should not start in user seed mode"
8942        );
8943
8944        // Enable user seed mode.
8945        session.set_seed_mode(info_hash, true).await.unwrap();
8946        let stats_on = session.torrent_stats(info_hash).await.unwrap();
8947        assert!(
8948            stats_on.user_seed_mode,
8949            "stats should reflect user_seed_mode=true after enabling"
8950        );
8951
8952        // Disable it again.
8953        session.set_seed_mode(info_hash, false).await.unwrap();
8954        let stats_off = session.torrent_stats(info_hash).await.unwrap();
8955        assert!(
8956            !stats_off.user_seed_mode,
8957            "stats should reflect user_seed_mode=false after disabling"
8958        );
8959
8960        session.shutdown().await.unwrap();
8961    }
8962
8963    // ---- M159 Test: seed_mode_round_trip ----
8964
8965    #[tokio::test]
8966    async fn seed_mode_round_trip() {
8967        // Five flips in a row, exercising the actor's toggle idempotency and
8968        // piece-reservation cleanup logic. Must not panic and must leave the
8969        // flag consistent with the most recent call.
8970        let session = SessionHandle::start(test_settings()).await.unwrap();
8971        let data = vec![0xAB; 16384];
8972        let meta = make_test_torrent(&data, 16384);
8973        let storage = make_storage(&data, 16384);
8974        let info_hash = session
8975            .add_torrent_with_meta(meta.into(), Some(storage))
8976            .await
8977            .unwrap();
8978
8979        for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
8980            session.set_seed_mode(info_hash, *enabled).await.unwrap();
8981            let stats = session.torrent_stats(info_hash).await.unwrap();
8982            assert_eq!(
8983                stats.user_seed_mode, *enabled,
8984                "iteration {i}: stats.user_seed_mode should track the toggle"
8985            );
8986        }
8987
8988        session.shutdown().await.unwrap();
8989    }
8990
8991    // ---- M159 Test: seed_mode_missing_info_hash_errors ----
8992
8993    #[tokio::test]
8994    async fn seed_mode_missing_info_hash_errors() {
8995        let session = SessionHandle::start(test_settings()).await.unwrap();
8996        let fake =
8997            irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
8998        let err = session
8999            .set_seed_mode(fake, true)
9000            .await
9001            .expect_err("set_seed_mode on unknown info hash must return an error");
9002        match err {
9003            crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9004            other => panic!("expected TorrentNotFound, got {other:?}"),
9005        }
9006        session.shutdown().await.unwrap();
9007    }
9008
9009    // ---- M159 Test: seed_mode_idempotent ----
9010
9011    #[tokio::test]
9012    async fn seed_mode_idempotent() {
9013        // Setting the same value twice must not panic or corrupt state.
9014        let session = SessionHandle::start(test_settings()).await.unwrap();
9015        let data = vec![0xAB; 16384];
9016        let meta = make_test_torrent(&data, 16384);
9017        let storage = make_storage(&data, 16384);
9018        let info_hash = session
9019            .add_torrent_with_meta(meta.into(), Some(storage))
9020            .await
9021            .unwrap();
9022
9023        // Set true twice — second call is a no-op.
9024        session.set_seed_mode(info_hash, true).await.unwrap();
9025        session.set_seed_mode(info_hash, true).await.unwrap();
9026        assert!(
9027            session
9028                .torrent_stats(info_hash)
9029                .await
9030                .unwrap()
9031                .user_seed_mode
9032        );
9033
9034        // Set false twice — also a no-op the second time.
9035        session.set_seed_mode(info_hash, false).await.unwrap();
9036        session.set_seed_mode(info_hash, false).await.unwrap();
9037        assert!(
9038            !session
9039                .torrent_stats(info_hash)
9040                .await
9041                .unwrap()
9042                .user_seed_mode
9043        );
9044
9045        session.shutdown().await.unwrap();
9046    }
9047
9048    // ---- Test: add_tracker_increases_count ----
9049
9050    #[tokio::test]
9051    async fn add_tracker_increases_count() {
9052        let session = SessionHandle::start(test_settings()).await.unwrap();
9053        let data = vec![0xAB; 16384];
9054        let meta = make_test_torrent(&data, 16384);
9055        let storage = make_storage(&data, 16384);
9056        let info_hash = session
9057            .add_torrent_with_meta(meta.into(), Some(storage))
9058            .await
9059            .unwrap();
9060
9061        // Test torrent has no trackers initially.
9062        let before = session.tracker_list(info_hash).await.unwrap();
9063        assert!(before.is_empty());
9064
9065        // Add a tracker.
9066        session
9067            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9068            .await
9069            .unwrap();
9070
9071        let after = session.tracker_list(info_hash).await.unwrap();
9072        assert_eq!(after.len(), 1);
9073        assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9074
9075        session.shutdown().await.unwrap();
9076    }
9077
9078    // ---- Test: replace_trackers_replaces_all ----
9079
9080    #[tokio::test]
9081    async fn replace_trackers_replaces_all() {
9082        let session = SessionHandle::start(test_settings()).await.unwrap();
9083        let data = vec![0xAB; 16384];
9084        let meta = make_test_torrent(&data, 16384);
9085        let storage = make_storage(&data, 16384);
9086        let info_hash = session
9087            .add_torrent_with_meta(meta.into(), Some(storage))
9088            .await
9089            .unwrap();
9090
9091        // Add 2 trackers.
9092        session
9093            .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9094            .await
9095            .unwrap();
9096        session
9097            .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9098            .await
9099            .unwrap();
9100        assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9101
9102        // Replace with 1 different tracker.
9103        session
9104            .replace_trackers(
9105                info_hash,
9106                vec!["http://replacement.example.com/announce".into()],
9107            )
9108            .await
9109            .unwrap();
9110
9111        let after = session.tracker_list(info_hash).await.unwrap();
9112        assert_eq!(after.len(), 1);
9113        assert_eq!(after[0].url, "http://replacement.example.com/announce");
9114
9115        session.shutdown().await.unwrap();
9116    }
9117
9118    // ---- Test: add_tracker_deduplicates ----
9119
9120    #[tokio::test]
9121    async fn add_tracker_deduplicates() {
9122        let session = SessionHandle::start(test_settings()).await.unwrap();
9123        let data = vec![0xAB; 16384];
9124        let meta = make_test_torrent(&data, 16384);
9125        let storage = make_storage(&data, 16384);
9126        let info_hash = session
9127            .add_torrent_with_meta(meta.into(), Some(storage))
9128            .await
9129            .unwrap();
9130
9131        // Add the same tracker URL twice.
9132        session
9133            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9134            .await
9135            .unwrap();
9136        session
9137            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9138            .await
9139            .unwrap();
9140
9141        // Should only have 1 tracker (deduplicated).
9142        let trackers = session.tracker_list(info_hash).await.unwrap();
9143        assert_eq!(trackers.len(), 1);
9144
9145        session.shutdown().await.unwrap();
9146    }
9147
9148    // ---- Test: info_hashes_matches_added_torrent ----
9149
9150    #[tokio::test]
9151    async fn info_hashes_matches_added_torrent() {
9152        let session = SessionHandle::start(test_settings()).await.unwrap();
9153        let data = vec![0xAB; 16384];
9154        let meta = make_test_torrent(&data, 16384);
9155        let expected_v1 = meta.info_hash;
9156        let storage = make_storage(&data, 16384);
9157
9158        let info_hash = session
9159            .add_torrent_with_meta(meta.into(), Some(storage))
9160            .await
9161            .unwrap();
9162        let hashes = session.info_hashes(info_hash).await.unwrap();
9163        assert_eq!(hashes.v1, Some(expected_v1));
9164        // v1-only torrent should not have v2 hash
9165        assert!(hashes.v2.is_none());
9166
9167        session.shutdown().await.unwrap();
9168    }
9169
9170    // ---- Test: torrent_file_returns_meta ----
9171
9172    #[tokio::test]
9173    async fn torrent_file_returns_meta() {
9174        let session = SessionHandle::start(test_settings()).await.unwrap();
9175        let data = vec![0xAB; 32768];
9176        let meta = make_test_torrent(&data, 16384);
9177        let storage = make_storage(&data, 16384);
9178
9179        let info_hash = session
9180            .add_torrent_with_meta(meta.into(), Some(storage))
9181            .await
9182            .unwrap();
9183        let torrent = session.torrent_file(info_hash).await.unwrap();
9184        assert!(torrent.is_some());
9185        let torrent = torrent.unwrap();
9186        assert_eq!(torrent.info_hash, info_hash);
9187        assert_eq!(torrent.info.name, "test");
9188        assert_eq!(torrent.info.total_length(), 32768);
9189
9190        session.shutdown().await.unwrap();
9191    }
9192
9193    // ---- Test: torrent_file_none_before_metadata ----
9194
9195    #[tokio::test]
9196    async fn torrent_file_none_before_metadata() {
9197        let session = SessionHandle::start(test_settings()).await.unwrap();
9198        let magnet = irontide_core::Magnet::parse(
9199            "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9200        )
9201        .unwrap();
9202
9203        let info_hash = session.add_magnet(magnet).await.unwrap();
9204        let torrent = session.torrent_file(info_hash).await.unwrap();
9205        // Before metadata is received, torrent_file should return None.
9206        assert!(torrent.is_none());
9207
9208        session.shutdown().await.unwrap();
9209    }
9210
9211    // ---- Test: force_dht_announce_no_error ----
9212
9213    #[tokio::test]
9214    async fn force_dht_announce_no_error() {
9215        let session = SessionHandle::start(test_settings()).await.unwrap();
9216        let data = vec![0xAB; 16384];
9217        let meta = make_test_torrent(&data, 16384);
9218        let storage = make_storage(&data, 16384);
9219        let info_hash = session
9220            .add_torrent_with_meta(meta.into(), Some(storage))
9221            .await
9222            .unwrap();
9223
9224        // Should succeed even without DHT enabled (no-op, no error).
9225        let result = session.force_dht_announce(info_hash).await;
9226        assert!(
9227            result.is_ok(),
9228            "force_dht_announce should succeed: {result:?}"
9229        );
9230
9231        // Should fail for unknown torrent.
9232        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9233        assert!(session.force_dht_announce(fake).await.is_err());
9234
9235        session.shutdown().await.unwrap();
9236    }
9237
9238    // ---- Test: force_lsd_announce_no_error ----
9239
9240    #[tokio::test]
9241    async fn force_lsd_announce_no_error() {
9242        let session = SessionHandle::start(test_settings()).await.unwrap();
9243        let data = vec![0xAB; 16384];
9244        let meta = make_test_torrent(&data, 16384);
9245        let storage = make_storage(&data, 16384);
9246        let info_hash = session
9247            .add_torrent_with_meta(meta.into(), Some(storage))
9248            .await
9249            .unwrap();
9250
9251        // Should succeed even without LSD enabled (no-op announce, no error).
9252        let result = session.force_lsd_announce(info_hash).await;
9253        assert!(
9254            result.is_ok(),
9255            "force_lsd_announce should succeed: {result:?}"
9256        );
9257
9258        // Should fail for unknown torrent.
9259        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9260        assert!(session.force_lsd_announce(fake).await.is_err());
9261
9262        session.shutdown().await.unwrap();
9263    }
9264
9265    // ---- Test: read_piece_after_download ----
9266
9267    #[tokio::test]
9268    async fn read_piece_after_download() {
9269        let data = vec![0xCD; 32768]; // 2 pieces of 16384
9270        let meta = make_test_torrent(&data, 16384);
9271        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9272        let storage = Arc::new(MemoryStorage::new(lengths));
9273        // Pre-fill storage with the data
9274        storage.write_chunk(0, 0, &data[..16384]).unwrap();
9275        storage.write_chunk(1, 0, &data[16384..]).unwrap();
9276
9277        let session = SessionHandle::start(test_settings()).await.unwrap();
9278        let info_hash = session
9279            .add_torrent_with_meta(meta.into(), Some(storage))
9280            .await
9281            .unwrap();
9282
9283        // Read piece 0
9284        let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9285        assert_eq!(piece_data.len(), 16384);
9286        assert!(piece_data.iter().all(|&b| b == 0xCD));
9287
9288        // Read piece 1
9289        let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9290        assert_eq!(piece_data.len(), 16384);
9291        assert!(piece_data.iter().all(|&b| b == 0xCD));
9292
9293        // Out-of-range piece should fail
9294        let result = session.read_piece(info_hash, 999).await;
9295        assert!(result.is_err(), "read_piece out of range should fail");
9296
9297        // Unknown torrent should fail
9298        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9299        assert!(session.read_piece(fake, 0).await.is_err());
9300
9301        session.shutdown().await.unwrap();
9302    }
9303
9304    // ---- Test: flush_cache_completes ----
9305
9306    #[tokio::test]
9307    async fn flush_cache_completes() {
9308        let session = SessionHandle::start(test_settings()).await.unwrap();
9309        let data = vec![0xAB; 16384];
9310        let meta = make_test_torrent(&data, 16384);
9311        let storage = make_storage(&data, 16384);
9312        let info_hash = session
9313            .add_torrent_with_meta(meta.into(), Some(storage))
9314            .await
9315            .unwrap();
9316
9317        // flush_cache should succeed.
9318        let result = session.flush_cache(info_hash).await;
9319        assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9320
9321        // Should fail for unknown torrent.
9322        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9323        assert!(session.flush_cache(fake).await.is_err());
9324
9325        session.shutdown().await.unwrap();
9326    }
9327
9328    // ---- BEP 44 session API tests ----
9329
9330    fn test_settings_with_dht() -> Settings {
9331        let mut s = test_settings();
9332        s.enable_dht = true;
9333        s
9334    }
9335
9336    fn test_settings_with_lsd() -> Settings {
9337        let mut s = test_settings();
9338        s.enable_lsd = true;
9339        s
9340    }
9341
9342    #[tokio::test]
9343    async fn test_dht_disabled_returns_error() {
9344        let session = SessionHandle::start(test_settings()).await.unwrap();
9345
9346        // All 4 methods should fail with DhtDisabled when DHT is off
9347        let err = session
9348            .dht_put_immutable(b"test".to_vec())
9349            .await
9350            .unwrap_err();
9351        assert!(
9352            format!("{err:?}").contains("DhtDisabled"),
9353            "expected DhtDisabled, got {err:?}"
9354        );
9355
9356        let target = Id20::from([0u8; 20]);
9357        let err = session.dht_get_immutable(target).await.unwrap_err();
9358        assert!(
9359            format!("{err:?}").contains("DhtDisabled"),
9360            "expected DhtDisabled, got {err:?}"
9361        );
9362
9363        let err = session
9364            .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
9365            .await
9366            .unwrap_err();
9367        assert!(
9368            format!("{err:?}").contains("DhtDisabled"),
9369            "expected DhtDisabled, got {err:?}"
9370        );
9371
9372        let err = session
9373            .dht_get_mutable([42u8; 32], Vec::new())
9374            .await
9375            .unwrap_err();
9376        assert!(
9377            format!("{err:?}").contains("DhtDisabled"),
9378            "expected DhtDisabled, got {err:?}"
9379        );
9380
9381        session.shutdown().await.unwrap();
9382    }
9383
9384    #[tokio::test]
9385    async fn test_dht_put_get_immutable_round_trip() {
9386        let session = SessionHandle::start(test_settings_with_dht())
9387            .await
9388            .unwrap();
9389
9390        // Give DHT a moment to bootstrap (it won't find real nodes, but the handle should work)
9391        let value = b"hello BEP 44".to_vec();
9392        let target = session.dht_put_immutable(value.clone()).await.unwrap();
9393
9394        // The target should be the SHA-1 of the bencoded value
9395        // Try to get it back — since we're the only node, the local store should have it
9396        let got = session.dht_get_immutable(target).await.unwrap();
9397        assert_eq!(got, Some(value));
9398
9399        session.shutdown().await.unwrap();
9400    }
9401
9402    #[tokio::test]
9403    async fn test_dht_put_immutable_fires_alert() {
9404        use crate::alert::{AlertCategory, AlertKind};
9405
9406        let session = SessionHandle::start(test_settings_with_dht())
9407            .await
9408            .unwrap();
9409        let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
9410
9411        let value = b"alert test".to_vec();
9412        let target = session.dht_put_immutable(value).await.unwrap();
9413
9414        // Should receive DhtPutComplete alert
9415        let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
9416            .await
9417            .expect("timeout waiting for alert")
9418            .expect("alert channel closed");
9419
9420        match alert.kind {
9421            AlertKind::DhtPutComplete { target: t } => {
9422                assert_eq!(t, target);
9423            }
9424            other => panic!("expected DhtPutComplete, got {other:?}"),
9425        }
9426
9427        session.shutdown().await.unwrap();
9428    }
9429
9430    // ---- BEP 27: Private torrent LSD tests ----
9431
9432    /// Creates a private torrent (.torrent bytes with private=1 in the info dict).
9433    fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
9434        use serde::Serialize;
9435
9436        #[derive(Serialize)]
9437        struct Info<'a> {
9438            length: u64,
9439            name: &'a str,
9440            #[serde(rename = "piece length")]
9441            piece_length: u64,
9442            #[serde(with = "serde_bytes")]
9443            pieces: &'a [u8],
9444            private: i64,
9445        }
9446
9447        #[derive(Serialize)]
9448        struct Torrent<'a> {
9449            info: Info<'a>,
9450        }
9451
9452        let mut pieces = Vec::new();
9453        let mut offset = 0;
9454        while offset < data.len() {
9455            let end = (offset + piece_length as usize).min(data.len());
9456            let hash = irontide_core::sha1(&data[offset..end]);
9457            pieces.extend_from_slice(hash.as_bytes());
9458            offset = end;
9459        }
9460
9461        let t = Torrent {
9462            info: Info {
9463                length: data.len() as u64,
9464                name: "private-test",
9465                piece_length,
9466                pieces: &pieces,
9467                private: 1,
9468            },
9469        };
9470
9471        let bytes = irontide_bencode::to_bytes(&t).unwrap();
9472        torrent_from_bytes(&bytes).unwrap()
9473    }
9474
9475    #[test]
9476    fn is_private_true_via_parsed_meta() {
9477        // Verify that a torrent parsed from private .torrent bytes has private == Some(1)
9478        let data = vec![0xAB; 16384];
9479        let meta = make_private_torrent(&data, 16384);
9480        assert_eq!(
9481            meta.info.private,
9482            Some(1),
9483            "private field should be Some(1)"
9484        );
9485    }
9486
9487    #[test]
9488    fn is_private_false_for_public_torrent() {
9489        // Verify that a regular torrent has private == None
9490        let data = vec![0xAB; 16384];
9491        let meta = make_test_torrent(&data, 16384);
9492        assert_eq!(
9493            meta.info.private, None,
9494            "public torrent should have no private flag"
9495        );
9496    }
9497
9498    #[test]
9499    fn private_torrent_config_disables_lsd() {
9500        // Verify that TorrentConfig::default() has LSD enabled (so disable is meaningful)
9501        let config = TorrentConfig::default();
9502        assert!(
9503            config.enable_lsd,
9504            "default TorrentConfig should have LSD enabled"
9505        );
9506    }
9507
9508    #[tokio::test]
9509    async fn force_lsd_announce_private_torrent_returns_error() {
9510        let session = SessionHandle::start(test_settings()).await.unwrap();
9511        let data = vec![0xAB; 16384];
9512        let meta = make_private_torrent(&data, 16384);
9513        let storage = make_storage(&data, 16384);
9514        let info_hash = session
9515            .add_torrent_with_meta(meta.into(), Some(storage))
9516            .await
9517            .unwrap();
9518
9519        // BEP 27: force_lsd_announce on a private torrent must return an error
9520        let result = session.force_lsd_announce(info_hash).await;
9521        assert!(
9522            result.is_err(),
9523            "force_lsd_announce on private torrent should return error, got: {result:?}"
9524        );
9525        let err_str = format!("{:?}", result.unwrap_err());
9526        assert!(
9527            err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
9528            "expected InvalidSettings error, got: {err_str}"
9529        );
9530
9531        session.shutdown().await.unwrap();
9532    }
9533
9534    #[tokio::test]
9535    async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
9536        // v0.173.2 A10 companion to the DHT negative-form test added in T1.
9537        //
9538        // NEGATIVE form (codex finding #2): we only assert the BEP 27 gate doesn't
9539        // reject. We do NOT assert Ok — LSD may return Ok for a different reason
9540        // (e.g., self.lsd is None per session.rs:945's startup-failure swallow),
9541        // and asserting Ok would mask that case while still passing. We also
9542        // don't assert a specific Err — public torrents on an enabled-LSD session
9543        // typically return Ok, but a test-sandbox LSD init glitch could legitimately
9544        // error without the BEP 27 gate being involved.
9545        let session = SessionHandle::start(test_settings_with_lsd())
9546            .await
9547            .unwrap();
9548        let data = vec![0xAB; 16384];
9549        let meta = make_test_torrent(&data, 16384);
9550        let storage = make_storage(&data, 16384);
9551        let info_hash = session
9552            .add_torrent_with_meta(meta.into(), Some(storage))
9553            .await
9554            .unwrap();
9555
9556        let result = session.force_lsd_announce(info_hash).await;
9557        if let Err(e) = &result {
9558            assert!(
9559                !format!("{e:?}").contains("LSD disabled for private torrent"),
9560                "public torrent must NOT trigger BEP 27 error; got {e:?}"
9561            );
9562        }
9563
9564        session.shutdown().await.unwrap();
9565    }
9566
9567    #[tokio::test]
9568    async fn force_dht_announce_private_torrent_returns_error() {
9569        let session = SessionHandle::start(test_settings_with_dht())
9570            .await
9571            .unwrap();
9572        let data = vec![0xAB; 16384];
9573        let meta = make_private_torrent(&data, 16384);
9574        let storage = make_storage(&data, 16384);
9575        let info_hash = session
9576            .add_torrent_with_meta(meta.into(), Some(storage))
9577            .await
9578            .unwrap();
9579
9580        // BEP 27: force_dht_announce on a private torrent must return an error
9581        let result = session.force_dht_announce(info_hash).await;
9582        assert!(
9583            result.is_err(),
9584            "force_dht_announce on private torrent should return error, got: {result:?}"
9585        );
9586        let err_str = format!("{:?}", result.unwrap_err());
9587        assert!(
9588            err_str.contains("InvalidSettings")
9589                || err_str.contains("DHT disabled for private torrent"),
9590            "expected InvalidSettings / DHT-disabled error, got: {err_str}"
9591        );
9592
9593        session.shutdown().await.unwrap();
9594    }
9595
9596    #[tokio::test]
9597    async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
9598        let session = SessionHandle::start(test_settings_with_dht())
9599            .await
9600            .unwrap();
9601        let data = vec![0xAB; 16384];
9602        let meta = make_test_torrent(&data, 16384);
9603        let storage = make_storage(&data, 16384);
9604        let info_hash = session
9605            .add_torrent_with_meta(meta.into(), Some(storage))
9606            .await
9607            .unwrap();
9608
9609        let result = session.force_dht_announce(info_hash).await;
9610        // NEGATIVE form: we only assert the BEP 27 gate doesn't reject. We do NOT
9611        // assert Ok — DHT may not be initialised in the test sandbox, returning a
9612        // different error. Asserting Ok here would mask both the test sandbox
9613        // limitation AND a future regression where the BEP 27 gate accidentally
9614        // catches public torrents.
9615        if let Err(e) = &result {
9616            assert!(
9617                !format!("{e:?}").contains("DHT disabled for private torrent"),
9618                "public torrent must NOT trigger BEP 27 error; got {e:?}"
9619            );
9620        }
9621
9622        session.shutdown().await.unwrap();
9623    }
9624
9625    // ---- M161: save_resume_state tests ----
9626
9627    fn resume_test_settings(dir: &std::path::Path) -> Settings {
9628        Settings {
9629            resume_data_dir: Some(dir.to_path_buf()),
9630            save_resume_interval_secs: 0, // disable periodic timer for tests
9631            ..test_settings()
9632        }
9633    }
9634
9635    #[tokio::test]
9636    async fn save_resume_state_empty_session_returns_zero() {
9637        let tmp = tempfile::TempDir::new().unwrap();
9638        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9639            .await
9640            .unwrap();
9641
9642        let count = session.save_resume_state().await.unwrap();
9643        assert_eq!(count, 0, "empty session should save 0 resume files");
9644
9645        session.shutdown().await.unwrap();
9646    }
9647
9648    #[tokio::test]
9649    async fn save_resume_state_saves_dirty_torrents() {
9650        let tmp = tempfile::TempDir::new().unwrap();
9651        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9652            .await
9653            .unwrap();
9654
9655        // Add two torrents with different data so they get different hashes
9656        let data1 = vec![0xAA; 16384];
9657        let meta1 = make_test_torrent(&data1, 16384);
9658        let hash1 = meta1.info_hash;
9659        let storage1 = make_storage(&data1, 16384);
9660        session
9661            .add_torrent_with_meta(meta1.into(), Some(storage1))
9662            .await
9663            .unwrap();
9664
9665        let data2 = vec![0xBB; 16384];
9666        let meta2 = make_test_torrent(&data2, 16384);
9667        let hash2 = meta2.info_hash;
9668        let storage2 = make_storage(&data2, 16384);
9669        session
9670            .add_torrent_with_meta(meta2.into(), Some(storage2))
9671            .await
9672            .unwrap();
9673
9674        // Both torrents should be dirty (newly added → need_save_resume = true
9675        // after any state change). Give the actors a moment to settle.
9676        tokio::time::sleep(Duration::from_millis(50)).await;
9677
9678        let count = session.save_resume_state().await.unwrap();
9679        // Newly created torrents may or may not have the dirty flag set
9680        // depending on whether state changes have occurred. Verify that
9681        // at least the files are created for any that were dirty.
9682        assert!(count <= 2, "should save at most 2 resume files");
9683
9684        // Verify files exist on disk for saved torrents
9685        let torrents_dir = tmp.path().join("torrents");
9686        if count > 0 {
9687            assert!(torrents_dir.exists(), "torrents/ directory should exist");
9688        }
9689
9690        // Check specific file paths
9691        let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
9692        let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
9693        let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
9694        assert_eq!(
9695            files_exist, count,
9696            "number of files on disk should match returned count"
9697        );
9698
9699        session.shutdown().await.unwrap();
9700    }
9701
9702    #[tokio::test]
9703    async fn save_resume_state_round_trip() {
9704        let tmp = tempfile::TempDir::new().unwrap();
9705        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9706            .await
9707            .unwrap();
9708
9709        let data = vec![0xCD; 32768];
9710        let meta = make_test_torrent(&data, 16384);
9711        let info_hash = meta.info_hash;
9712        let storage = make_storage(&data, 16384);
9713        session
9714            .add_torrent_with_meta(meta.into(), Some(storage))
9715            .await
9716            .unwrap();
9717
9718        // Let the actor settle so dirty flag is set
9719        tokio::time::sleep(Duration::from_millis(50)).await;
9720
9721        let count = session.save_resume_state().await.unwrap();
9722
9723        // If the torrent was dirty and saved, verify round-trip
9724        if count > 0 {
9725            let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
9726            assert!(path.exists(), "resume file should exist after save");
9727
9728            let bytes = std::fs::read(&path).unwrap();
9729            let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
9730            assert_eq!(
9731                rd.info_hash,
9732                info_hash.as_bytes().to_vec(),
9733                "deserialized info_hash should match"
9734            );
9735            assert_eq!(rd.name, "test", "deserialized name should match");
9736        }
9737
9738        session.shutdown().await.unwrap();
9739    }
9740
9741    #[tokio::test]
9742    async fn save_resume_state_clears_dirty_flag() {
9743        let tmp = tempfile::TempDir::new().unwrap();
9744        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9745            .await
9746            .unwrap();
9747
9748        let data = vec![0xEE; 16384];
9749        let meta = make_test_torrent(&data, 16384);
9750        let storage = make_storage(&data, 16384);
9751        session
9752            .add_torrent_with_meta(meta.into(), Some(storage))
9753            .await
9754            .unwrap();
9755
9756        tokio::time::sleep(Duration::from_millis(50)).await;
9757
9758        let first_count = session.save_resume_state().await.unwrap();
9759
9760        // Second save should return 0 because dirty flag was cleared
9761        let second_count = session.save_resume_state().await.unwrap();
9762        assert_eq!(
9763            second_count, 0,
9764            "second save should return 0 after dirty flag cleared (first saved {first_count})"
9765        );
9766
9767        session.shutdown().await.unwrap();
9768    }
9769
9770    #[tokio::test]
9771    async fn save_resume_state_second_save_skips_clean() {
9772        let tmp = tempfile::TempDir::new().unwrap();
9773        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9774            .await
9775            .unwrap();
9776
9777        let data1 = vec![0xAA; 16384];
9778        let meta1 = make_test_torrent(&data1, 16384);
9779        let storage1 = make_storage(&data1, 16384);
9780        session
9781            .add_torrent_with_meta(meta1.into(), Some(storage1))
9782            .await
9783            .unwrap();
9784
9785        let data2 = vec![0xBB; 16384];
9786        let meta2 = make_test_torrent(&data2, 16384);
9787        let storage2 = make_storage(&data2, 16384);
9788        session
9789            .add_torrent_with_meta(meta2.into(), Some(storage2))
9790            .await
9791            .unwrap();
9792
9793        tokio::time::sleep(Duration::from_millis(50)).await;
9794
9795        // First save
9796        let first = session.save_resume_state().await.unwrap();
9797
9798        // Second save — all flags should be cleared, nothing to write
9799        let second = session.save_resume_state().await.unwrap();
9800        assert_eq!(
9801            second, 0,
9802            "second save should skip all clean torrents (first saved {first})"
9803        );
9804
9805        session.shutdown().await.unwrap();
9806    }
9807
9808    // ==== M161 Phase 4: load_resume_state tests ====
9809
9810    // ---- Test: empty dir → zeros ----
9811
9812    #[tokio::test]
9813    async fn load_resume_empty_dir_returns_zeros() {
9814        let tmp = tempfile::TempDir::new().unwrap();
9815        let mut settings = test_settings();
9816        settings.resume_data_dir = Some(tmp.path().to_path_buf());
9817
9818        let session = SessionHandle::start(settings).await.unwrap();
9819        let result = session.load_resume_state().await.unwrap();
9820        assert_eq!(result.restored, 0);
9821        assert_eq!(result.skipped, 0);
9822        assert_eq!(result.failed, 0);
9823
9824        session.shutdown().await.unwrap();
9825    }
9826
9827    // ---- Test: corrupt file skipped ----
9828
9829    #[tokio::test]
9830    async fn load_resume_corrupt_file_counted_as_failed() {
9831        let tmp = tempfile::TempDir::new().unwrap();
9832        let torrents_dir = tmp.path().join("torrents");
9833        std::fs::create_dir_all(&torrents_dir).unwrap();
9834
9835        let mut settings = test_settings();
9836        settings.resume_data_dir = Some(tmp.path().to_path_buf());
9837
9838        // Start session first (auto-restore runs but dir is empty).
9839        let session = SessionHandle::start(settings).await.unwrap();
9840
9841        // Wait for auto-restore to complete before writing the file,
9842        // otherwise the actor may race with file creation.
9843        tokio::time::sleep(Duration::from_millis(50)).await;
9844
9845        // Write garbage to a .resume file *after* startup so auto-restore
9846        // does not consume it.
9847        std::fs::write(
9848            torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
9849            b"this is not valid bencode",
9850        )
9851        .unwrap();
9852
9853        let result = session.load_resume_state().await.unwrap();
9854        assert_eq!(result.restored, 0);
9855        assert_eq!(result.skipped, 0);
9856        assert_eq!(result.failed, 1);
9857
9858        session.shutdown().await.unwrap();
9859    }
9860
9861    // ---- Test: duplicate torrent skipped ----
9862
9863    #[tokio::test]
9864    async fn load_resume_duplicate_skipped() {
9865        let tmp = tempfile::TempDir::new().unwrap();
9866        let mut settings = test_settings();
9867        settings.resume_data_dir = Some(tmp.path().to_path_buf());
9868
9869        let session = SessionHandle::start(settings).await.unwrap();
9870
9871        // Add a torrent first.
9872        let data = vec![0xAB; 16384];
9873        let meta = make_test_torrent(&data, 16384);
9874        let info_hash = meta.info_hash;
9875        let storage = make_storage(&data, 16384);
9876        session
9877            .add_torrent_with_meta(meta.into(), Some(storage))
9878            .await
9879            .unwrap();
9880
9881        // Wait for the torrent to settle.
9882        tokio::time::sleep(Duration::from_millis(50)).await;
9883
9884        // Save resume state so we have a file on disk.
9885        let _ = session.save_resume_state().await;
9886
9887        // Load again — the torrent already exists so it should be skipped.
9888        let result = session.load_resume_state().await.unwrap();
9889        assert!(
9890            session.list_torrents().await.unwrap().contains(&info_hash),
9891            "original torrent should still exist"
9892        );
9893        assert_eq!(result.skipped, 1, "duplicate should be skipped");
9894        assert_eq!(result.failed, 0);
9895
9896        session.shutdown().await.unwrap();
9897    }
9898
9899    // ---- Test: reconstruct_torrent_meta with info present ----
9900
9901    #[test]
9902    fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
9903        use crate::resume_file::reconstruct_torrent_meta;
9904        use irontide_core::FastResumeData;
9905
9906        let data = vec![0xAB; 16384];
9907        let meta = make_test_torrent(&data, 16384);
9908        let info_hash = meta.info_hash;
9909
9910        // Create resume data with a stored info dict.
9911        let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
9912        let mut rd = FastResumeData::new(
9913            info_hash.as_bytes().to_vec(),
9914            "test-torrent".into(),
9915            "/downloads".into(),
9916        );
9917        rd.info = Some(info_bytes);
9918        rd.trackers = vec![
9919            vec!["http://tracker1.example.com/announce".into()],
9920            vec!["http://tracker2.example.com/announce".into()],
9921        ];
9922        rd.url_seeds = vec!["http://seed.example.com/".into()];
9923        rd.http_seeds = vec!["http://httpseed.example.com/".into()];
9924
9925        let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
9926
9927        assert_eq!(reconstructed.info_hash, info_hash);
9928        assert_eq!(
9929            reconstructed.announce.as_deref(),
9930            Some("http://tracker1.example.com/announce")
9931        );
9932        assert!(reconstructed.announce_list.is_some());
9933        assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
9934        assert_eq!(
9935            reconstructed.url_list,
9936            vec!["http://seed.example.com/".to_string()]
9937        );
9938        assert_eq!(
9939            reconstructed.httpseeds,
9940            vec!["http://httpseed.example.com/".to_string()]
9941        );
9942        assert!(reconstructed.info_bytes.is_some());
9943        assert!(reconstructed.comment.is_none());
9944        assert!(reconstructed.created_by.is_none());
9945        assert!(reconstructed.creation_date.is_none());
9946    }
9947
9948    // ---- Test: reconstruct_torrent_meta returns None for unresolved magnet ----
9949
9950    #[test]
9951    fn reconstruct_torrent_meta_returns_none_without_info() {
9952        use crate::resume_file::reconstruct_torrent_meta;
9953        use irontide_core::FastResumeData;
9954
9955        let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
9956        // info is None by default — simulates unresolved magnet.
9957        assert!(rd.info.is_none());
9958        assert!(reconstruct_torrent_meta(&rd).is_none());
9959    }
9960
9961    // ---- Test: reconstruct_magnet returns Some ----
9962
9963    #[test]
9964    fn reconstruct_magnet_returns_some_with_correct_fields() {
9965        use crate::resume_file::reconstruct_magnet;
9966        use irontide_core::FastResumeData;
9967
9968        let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
9969        rd.trackers = vec![
9970            vec!["http://tracker1.com/announce".into()],
9971            vec![
9972                "http://tracker2.com/announce".into(),
9973                "http://tracker3.com/announce".into(),
9974            ],
9975        ];
9976
9977        let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
9978
9979        assert!(magnet.info_hashes.v1.is_some());
9980        assert!(magnet.info_hashes.v2.is_none());
9981        assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
9982        // Trackers flattened: 3 total from 2 tiers.
9983        assert_eq!(magnet.trackers.len(), 3);
9984        assert!(magnet.peers.is_empty());
9985        assert!(magnet.selected_files.is_none());
9986    }
9987
9988    // ---- Test: reconstruct_magnet with info_hash2 preserved ----
9989
9990    #[test]
9991    fn reconstruct_magnet_preserves_info_hash2() {
9992        use crate::resume_file::reconstruct_magnet;
9993        use irontide_core::FastResumeData;
9994
9995        let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
9996        rd.info_hash2 = Some(vec![0xEE; 32]);
9997
9998        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
9999        assert!(magnet.info_hashes.v1.is_some());
10000        assert!(magnet.info_hashes.v2.is_some());
10001
10002        let v2 = magnet.info_hashes.v2.unwrap();
10003        assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10004    }
10005
10006    // ---- Test: reconstruct_magnet with empty name ----
10007
10008    #[test]
10009    fn reconstruct_magnet_empty_name_is_none() {
10010        use crate::resume_file::reconstruct_magnet;
10011        use irontide_core::FastResumeData;
10012
10013        let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10014        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10015        assert!(
10016            magnet.display_name.is_none(),
10017            "empty name should map to None"
10018        );
10019    }
10020
10021    // ==== M161 Phase 5: auto-save / auto-restore / orphan cleanup ====
10022
10023    // ---- Test: shutdown writes resume files ----
10024
10025    #[tokio::test]
10026    async fn shutdown_saves_resume_files() {
10027        let tmp = tempfile::TempDir::new().unwrap();
10028        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10029            .await
10030            .unwrap();
10031
10032        let data = vec![0xAB; 16384];
10033        let meta = make_test_torrent(&data, 16384);
10034        let info_hash = meta.info_hash;
10035        let storage = make_storage(&data, 16384);
10036        session
10037            .add_torrent_with_meta(meta.into(), Some(storage))
10038            .await
10039            .unwrap();
10040
10041        // Force a state change to set the dirty flag: pause then resume.
10042        session.pause_torrent(info_hash).await.unwrap();
10043        tokio::time::sleep(Duration::from_millis(50)).await;
10044        session.resume_torrent(info_hash).await.unwrap();
10045        tokio::time::sleep(Duration::from_millis(50)).await;
10046
10047        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10048
10049        // Shutdown triggers save_dirty_resume_files internally.
10050        // SessionHandle::shutdown() is fire-and-forget, so we need to
10051        // wait briefly for the actor to finish writing to disk.
10052        session.shutdown().await.unwrap();
10053        tokio::time::sleep(Duration::from_millis(200)).await;
10054
10055        assert!(path.exists(), "resume file should exist after shutdown");
10056    }
10057
10058    // ---- Test: auto-restore on startup ----
10059
10060    #[tokio::test]
10061    async fn auto_restore_on_startup() {
10062        let tmp = tempfile::TempDir::new().unwrap();
10063
10064        let info_hash;
10065        {
10066            // First session: add a torrent, save, and shut down.
10067            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10068                .await
10069                .unwrap();
10070
10071            let data = vec![0xAB; 16384];
10072            let meta = make_test_torrent(&data, 16384);
10073            info_hash = meta.info_hash;
10074            let storage = make_storage(&data, 16384);
10075            session
10076                .add_torrent_with_meta(meta.into(), Some(storage))
10077                .await
10078                .unwrap();
10079
10080            tokio::time::sleep(Duration::from_millis(50)).await;
10081            let _ = session.save_resume_state().await;
10082            session.shutdown().await.unwrap();
10083        }
10084
10085        // Verify the resume file exists before starting a new session.
10086        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10087        assert!(path.exists(), "resume file should exist before restart");
10088
10089        {
10090            // Second session: should auto-restore the torrent on startup.
10091            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10092                .await
10093                .unwrap();
10094
10095            // Give the actor a moment to process the auto-restore.
10096            tokio::time::sleep(Duration::from_millis(100)).await;
10097
10098            let list = session.list_torrents().await.unwrap();
10099            assert!(
10100                list.contains(&info_hash),
10101                "torrent should be auto-restored on startup"
10102            );
10103
10104            session.shutdown().await.unwrap();
10105        }
10106    }
10107
10108    // ---- Test: shutdown with read-only resume dir completes without error ----
10109
10110    #[tokio::test]
10111    async fn shutdown_with_readonly_resume_dir_completes() {
10112        let tmp = tempfile::TempDir::new().unwrap();
10113        // Point resume_data_dir to a non-existent path under a read-only root.
10114        // On Linux, /proc is always read-only for directory creation.
10115        let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10116        let mut settings = test_settings();
10117        settings.resume_data_dir = Some(readonly_dir);
10118
10119        let session = SessionHandle::start(settings).await.unwrap();
10120
10121        let data = vec![0xAB; 16384];
10122        let meta = make_test_torrent(&data, 16384);
10123        let storage = make_storage(&data, 16384);
10124        session
10125            .add_torrent_with_meta(meta.into(), Some(storage))
10126            .await
10127            .unwrap();
10128
10129        tokio::time::sleep(Duration::from_millis(50)).await;
10130
10131        // Shutdown should complete without panic or error even though
10132        // the resume dir is not writable.
10133        session.shutdown().await.unwrap();
10134
10135        // If we got here, the test passed — errors were logged, not propagated.
10136        drop(tmp);
10137    }
10138
10139    // ---- Test: orphan resume file deleted on startup ----
10140
10141    #[tokio::test]
10142    async fn orphan_resume_file_deleted_on_startup() {
10143        let tmp = tempfile::TempDir::new().unwrap();
10144        let torrents_dir = tmp.path().join("torrents");
10145        std::fs::create_dir_all(&torrents_dir).unwrap();
10146
10147        // Write a fake .resume file that does not match any torrent.
10148        // Use valid bencode so it parses but with a hash that won't match
10149        // anything added to the session. The file must parse correctly for
10150        // the load to attempt adding it (which will fail or produce a torrent
10151        // with a mismatched hash that gets cleaned up as orphan).
10152        // Simplest: write garbage bencode — it will fail to deserialize,
10153        // not be added, and then orphan cleanup should remove it.
10154        let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10155        std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10156        assert!(orphan_path.exists(), "orphan file should exist before test");
10157
10158        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10159            .await
10160            .unwrap();
10161
10162        // Give the actor time to auto-restore + orphan cleanup.
10163        tokio::time::sleep(Duration::from_millis(100)).await;
10164
10165        assert!(
10166            !orphan_path.exists(),
10167            "orphan resume file should be deleted on startup"
10168        );
10169
10170        session.shutdown().await.unwrap();
10171    }
10172
10173    // ==== M161 Phase 7: integration tests for resume file lifecycle ====
10174
10175    // ---- Test: multi-torrent save-load round-trip ----
10176    //
10177    // Creates 3 torrents in session 1, saves resume state, verifies 3 `.resume`
10178    // files on disk. Starts session 2 with the same resume dir and verifies all
10179    // 3 torrents are restored via `load_resume_state()`.
10180
10181    #[tokio::test]
10182    async fn multi_torrent_save_load_round_trip() {
10183        let tmp = tempfile::TempDir::new().unwrap();
10184
10185        // Distinct data per torrent to produce unique info hashes.
10186        let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10187        let mut hashes = Vec::with_capacity(3);
10188
10189        {
10190            // Session 1: add 3 torrents, save resume state.
10191            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10192                .await
10193                .unwrap();
10194
10195            for &byte in &datasets {
10196                let data = vec![byte; 16384];
10197                let meta = make_test_torrent(&data, 16384);
10198                let info_hash = meta.info_hash;
10199                let storage = make_storage(&data, 16384);
10200                session
10201                    .add_torrent_with_meta(meta.into(), Some(storage))
10202                    .await
10203                    .unwrap();
10204                hashes.push(info_hash);
10205            }
10206
10207            // Let actors settle so dirty flags are set.
10208            tokio::time::sleep(Duration::from_millis(100)).await;
10209
10210            let saved = session.save_resume_state().await.unwrap();
10211            assert_eq!(saved, 3, "all 3 torrents should be saved");
10212
10213            // Verify .resume files exist on disk.
10214            let files = crate::resume_file::scan_resume_dir(tmp.path());
10215            assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10216
10217            for hash in &hashes {
10218                let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10219                assert!(
10220                    path.exists(),
10221                    "resume file for {} should exist",
10222                    hex::encode(hash.as_bytes())
10223                );
10224            }
10225
10226            session.shutdown().await.unwrap();
10227        }
10228
10229        {
10230            // Session 2: fresh session with the same resume dir.
10231            // Disable auto-restore by starting first, then calling
10232            // load_resume_state manually.
10233            //
10234            // NOTE: the auto-restore runs during `start()` before we get the
10235            // handle back, so the torrents will already be loaded. Use
10236            // list_torrents to verify instead.
10237            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10238                .await
10239                .unwrap();
10240
10241            // Give the actor time to process auto-restore.
10242            tokio::time::sleep(Duration::from_millis(200)).await;
10243
10244            let list = session.list_torrents().await.unwrap();
10245            assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10246
10247            for hash in &hashes {
10248                assert!(
10249                    list.contains(hash),
10250                    "torrent {} should be present after restore",
10251                    hex::encode(hash.as_bytes())
10252                );
10253            }
10254
10255            session.shutdown().await.unwrap();
10256        }
10257    }
10258
10259    // ---- Test: corrupt 1 of 3 resume files → 2 restored + 1 failed ----
10260    //
10261    // Saves 3 torrents to resume files, corrupts one with garbage bytes,
10262    // then starts a fresh session and verifies that 2 are restored and 1 failed.
10263
10264    #[tokio::test]
10265    async fn corrupt_one_of_three_resume_files() {
10266        let tmp = tempfile::TempDir::new().unwrap();
10267
10268        let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10269        let mut hashes = Vec::with_capacity(3);
10270
10271        {
10272            // Session 1: add 3 torrents, save resume state.
10273            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10274                .await
10275                .unwrap();
10276
10277            for &byte in &datasets {
10278                let data = vec![byte; 16384];
10279                let meta = make_test_torrent(&data, 16384);
10280                let info_hash = meta.info_hash;
10281                let storage = make_storage(&data, 16384);
10282                session
10283                    .add_torrent_with_meta(meta.into(), Some(storage))
10284                    .await
10285                    .unwrap();
10286                hashes.push(info_hash);
10287            }
10288
10289            tokio::time::sleep(Duration::from_millis(100)).await;
10290
10291            let saved = session.save_resume_state().await.unwrap();
10292            assert_eq!(saved, 3, "all 3 torrents should be saved");
10293
10294            session.shutdown().await.unwrap();
10295        }
10296
10297        // Corrupt the second resume file with garbage bytes.
10298        let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10299        assert!(
10300            corrupt_path.exists(),
10301            "file to corrupt must exist before overwrite"
10302        );
10303        std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10304
10305        {
10306            // Session 2: auto-restore should recover 2, fail 1, and the
10307            // orphan cleanup should delete the corrupt file.
10308            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10309                .await
10310                .unwrap();
10311
10312            // Give actor time for auto-restore + orphan cleanup.
10313            tokio::time::sleep(Duration::from_millis(200)).await;
10314
10315            let list = session.list_torrents().await.unwrap();
10316            assert_eq!(
10317                list.len(),
10318                2,
10319                "2 torrents should be restored (1 corrupt skipped)"
10320            );
10321
10322            // The good hashes should be present.
10323            assert!(
10324                list.contains(&hashes[0]),
10325                "first torrent should be restored"
10326            );
10327            assert!(
10328                list.contains(&hashes[2]),
10329                "third torrent should be restored"
10330            );
10331
10332            // The corrupt hash should NOT be present.
10333            assert!(
10334                !list.contains(&hashes[1]),
10335                "corrupted torrent should not be restored"
10336            );
10337
10338            // Also verify the corrupt file was cleaned up as orphan.
10339            assert!(
10340                !corrupt_path.exists(),
10341                "corrupt resume file should be deleted by orphan cleanup"
10342            );
10343
10344            session.shutdown().await.unwrap();
10345        }
10346    }
10347
10348    // ---- Test: remove torrent → `.resume` file deleted from disk ----
10349    //
10350    // Adds a torrent, saves resume state (creates the `.resume` file), then
10351    // removes the torrent via `session.remove_torrent()`. The removal handler
10352    // eagerly deletes the `.resume` file so it is not orphaned.
10353
10354    #[tokio::test]
10355    async fn remove_torrent_deletes_resume_file() {
10356        let tmp = tempfile::TempDir::new().unwrap();
10357
10358        let data = vec![0x42; 16384];
10359        let meta = make_test_torrent(&data, 16384);
10360        let info_hash = meta.info_hash;
10361        let storage = make_storage(&data, 16384);
10362
10363        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10364            .await
10365            .unwrap();
10366
10367        session
10368            .add_torrent_with_meta(meta.into(), Some(storage))
10369            .await
10370            .unwrap();
10371
10372        // Let the actor settle so the dirty flag is set.
10373        tokio::time::sleep(Duration::from_millis(100)).await;
10374
10375        let saved = session.save_resume_state().await.unwrap();
10376        assert!(saved > 0, "torrent should be saved to a resume file");
10377
10378        let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10379        assert!(resume_path.exists(), "resume file should exist after save");
10380
10381        // Remove the torrent — this should also delete the .resume file.
10382        session.remove_torrent(info_hash).await.unwrap();
10383        tokio::time::sleep(Duration::from_millis(50)).await;
10384
10385        let list = session.list_torrents().await.unwrap();
10386        assert!(
10387            !list.contains(&info_hash),
10388            "torrent should be gone from session after removal"
10389        );
10390
10391        assert!(
10392            !resume_path.exists(),
10393            "resume file should be deleted when torrent is removed"
10394        );
10395
10396        // Verify no .resume files remain in the torrents directory.
10397        let remaining = crate::resume_file::scan_resume_dir(tmp.path());
10398        assert!(
10399            remaining.is_empty(),
10400            "no resume files should remain after removing the only torrent"
10401        );
10402
10403        session.shutdown().await.unwrap();
10404    }
10405
10406    // ── M170: session-level storage-path tests ─────────────────────────
10407
10408    /// Test settings that use an isolated `resume_data_dir` so that
10409    /// auto-restore from a prior test run doesn't pollute `torrents`.
10410    /// Matches the precedent set by `test_settings_with_dht`.
10411    fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
10412        Settings {
10413            resume_data_dir: Some(resume_dir.to_path_buf()),
10414            ..test_settings()
10415        }
10416    }
10417
10418    #[tokio::test]
10419    async fn remove_torrent_with_files_deletes_disk_files() {
10420        // Build a real on-disk torrent via FilesystemStorage, then call
10421        // remove_torrent_with_files. The files MUST be gone from disk
10422        // after the spawn_blocking walk completes.
10423        let download_dir = tempfile::tempdir().unwrap();
10424        let resume_dir = tempfile::tempdir().unwrap();
10425        let mut settings = test_settings_isolated_resume(resume_dir.path());
10426        settings.download_dir = download_dir.path().to_path_buf();
10427        let session = SessionHandle::start(settings).await.unwrap();
10428
10429        let data = vec![0xAB_u8; 16384];
10430        let meta = make_test_torrent(&data, 16384);
10431        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10432        let storage: Arc<dyn TorrentStorage> = Arc::new(
10433            irontide_storage::FilesystemStorage::new(
10434                download_dir.path(),
10435                vec![PathBuf::from("test")],
10436                vec![data.len() as u64],
10437                lengths,
10438                None,
10439                irontide_storage::PreallocateMode::None,
10440                false,
10441            )
10442            .unwrap(),
10443        );
10444
10445        // Write actual piece data so the file is non-empty and cannot
10446        // be mistaken for a sparse leftover.
10447        storage.write_chunk(0, 0, &data).unwrap();
10448
10449        let info_hash = session
10450            .add_torrent_with_meta(meta.into(), Some(storage))
10451            .await
10452            .unwrap();
10453
10454        let file_on_disk = download_dir.path().join("test");
10455        assert!(file_on_disk.exists(), "file should exist before delete");
10456
10457        session.remove_torrent_with_files(info_hash).await.unwrap();
10458
10459        // The spawn_blocking task is fire-and-forget; poll briefly.
10460        for _ in 0..20 {
10461            if !file_on_disk.exists() {
10462                break;
10463            }
10464            tokio::time::sleep(Duration::from_millis(50)).await;
10465        }
10466        assert!(
10467            !file_on_disk.exists(),
10468            "file should have been removed from disk"
10469        );
10470        assert!(
10471            download_dir.path().exists(),
10472            "download_dir root must never be removed"
10473        );
10474
10475        session.shutdown().await.unwrap();
10476    }
10477
10478    #[tokio::test]
10479    async fn remove_torrent_with_files_tolerates_already_deleted_files() {
10480        // Partial-failure semantics: the user removed files out-of-band
10481        // before the session got the deleteFiles command. The call must
10482        // still succeed (always returns Ok).
10483        let download_dir = tempfile::tempdir().unwrap();
10484        let resume_dir = tempfile::tempdir().unwrap();
10485        let mut settings = test_settings_isolated_resume(resume_dir.path());
10486        settings.download_dir = download_dir.path().to_path_buf();
10487        let session = SessionHandle::start(settings).await.unwrap();
10488
10489        let data = vec![0xCD_u8; 16384];
10490        let meta = make_test_torrent(&data, 16384);
10491        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10492        let storage: Arc<dyn TorrentStorage> = Arc::new(
10493            irontide_storage::FilesystemStorage::new(
10494                download_dir.path(),
10495                vec![PathBuf::from("test")],
10496                vec![data.len() as u64],
10497                lengths,
10498                None,
10499                irontide_storage::PreallocateMode::None,
10500                false,
10501            )
10502            .unwrap(),
10503        );
10504        let info_hash = session
10505            .add_torrent_with_meta(meta.into(), Some(storage))
10506            .await
10507            .unwrap();
10508
10509        // Manually delete the file before calling remove_torrent_with_files.
10510        std::fs::remove_file(download_dir.path().join("test")).unwrap();
10511
10512        // Must still succeed.
10513        let result = session.remove_torrent_with_files(info_hash).await;
10514        assert!(
10515            result.is_ok(),
10516            "remove_torrent_with_files must return Ok on missing files"
10517        );
10518
10519        session.shutdown().await.unwrap();
10520    }
10521
10522    #[tokio::test]
10523    async fn remove_torrent_with_files_grace_guards_fast_re_add() {
10524        // Fast re-add during an in-flight delete must 409 until the
10525        // deletion grace window closes. Because the delete is fire-and-
10526        // forget, we simulate by calling remove_torrent_with_files and
10527        // then immediately re-adding the same info hash via
10528        // add_torrent(AddTorrentParams::bytes(...)).  The grace set is
10529        // populated synchronously inside the actor so the re-add sees it.
10530        use serde::Serialize;
10531
10532        #[derive(Serialize)]
10533        struct Info<'a> {
10534            length: u64,
10535            name: &'a str,
10536            #[serde(rename = "piece length")]
10537            piece_length: u64,
10538            #[serde(with = "serde_bytes")]
10539            pieces: &'a [u8],
10540        }
10541        #[derive(Serialize)]
10542        struct Torrent<'a> {
10543            info: Info<'a>,
10544        }
10545
10546        let download_dir = tempfile::tempdir().unwrap();
10547        let resume_dir = tempfile::tempdir().unwrap();
10548        let mut settings = test_settings_isolated_resume(resume_dir.path());
10549        settings.download_dir = download_dir.path().to_path_buf();
10550        let session = SessionHandle::start(settings).await.unwrap();
10551
10552        // Build + serialize a single-file torrent so we can re-add via
10553        // bytes after deleting.
10554        let data = vec![0xEE_u8; 16384];
10555        let meta = make_test_torrent(&data, 16384);
10556        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10557        let storage: Arc<dyn TorrentStorage> = Arc::new(
10558            irontide_storage::FilesystemStorage::new(
10559                download_dir.path(),
10560                vec![PathBuf::from("test")],
10561                vec![data.len() as u64],
10562                lengths,
10563                None,
10564                irontide_storage::PreallocateMode::None,
10565                false,
10566            )
10567            .unwrap(),
10568        );
10569        // Re-serialise the TorrentMetaV1 so we can feed the bytes back
10570        // through AddTorrentParams::bytes.
10571        let mut pieces = Vec::new();
10572        let hash = irontide_core::sha1(&data);
10573        pieces.extend_from_slice(hash.as_bytes());
10574        let bytes = irontide_bencode::to_bytes(&Torrent {
10575            info: Info {
10576                length: data.len() as u64,
10577                name: "test",
10578                piece_length: 16384,
10579                pieces: &pieces,
10580            },
10581        })
10582        .unwrap();
10583
10584        let info_hash = session
10585            .add_torrent_with_meta(meta.into(), Some(storage))
10586            .await
10587            .unwrap();
10588
10589        // Kick off the delete — the deletion_grace set is populated
10590        // inside the actor before we return.
10591        session.remove_torrent_with_files(info_hash).await.unwrap();
10592
10593        // Immediately try to re-add. The grace window may still be
10594        // open; if it is, we expect 409/CategoryBeingRemoved. If the
10595        // spawn_blocking happened to finish first, we expect success.
10596        // Either way the system must NOT panic or leak a half-deleted
10597        // torrent.
10598        let params = AddTorrentParams::bytes(bytes);
10599        let result = session.add_torrent(params).await;
10600        match result {
10601            Ok(_) => {
10602                // grace window closed before the re-add — fine.
10603            }
10604            Err(crate::Error::TorrentBeingRemoved(h)) => {
10605                assert_eq!(h, info_hash, "grace error must name the same hash");
10606            }
10607            Err(e) => panic!("unexpected error on re-add: {e}"),
10608        }
10609
10610        session.shutdown().await.unwrap();
10611    }
10612
10613    // ---- v0.173.2 T2: synchronous debug_inject_metadata ----
10614
10615    /// Synthesise a v1 info dict and its SHA-1 info hash. Returns
10616    /// `(info_bytes, info_hash)` where `info_bytes` is the bencoded info
10617    /// dict alone (not the outer .torrent wrapper) and `info_hash` is the
10618    /// SHA-1 of those bytes.
10619    ///
10620    /// The injected info hash MUST match the magnet URI's info hash, so
10621    /// this helper owns that invariant: hash exactly the bytes that will
10622    /// later be injected.
10623    #[cfg(feature = "test-util")]
10624    fn make_debug_inject_info() -> (Vec<u8>, Id20) {
10625        use serde::Serialize;
10626
10627        #[derive(Serialize)]
10628        struct Info<'a> {
10629            length: u64,
10630            name: &'a str,
10631            #[serde(rename = "piece length")]
10632            piece_length: u64,
10633            #[serde(with = "serde_bytes")]
10634            pieces: &'a [u8],
10635        }
10636
10637        let data = vec![0xAB_u8; 1024];
10638        let piece_hash = irontide_core::sha1(&data);
10639        let mut pieces = Vec::new();
10640        pieces.extend_from_slice(piece_hash.as_bytes());
10641
10642        let info = Info {
10643            length: data.len() as u64,
10644            name: "sync-inject-test",
10645            piece_length: 1024,
10646            pieces: &pieces,
10647        };
10648
10649        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
10650        let info_hash = irontide_core::sha1(&info_bytes);
10651        (info_bytes, info_hash)
10652    }
10653
10654    #[cfg(feature = "test-util")]
10655    #[tokio::test]
10656    async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
10657        use crate::session::AddTorrentParams;
10658
10659        let (info_bytes, info_hash) = make_debug_inject_info();
10660
10661        // Isolate resume dir — magnet adds persist .resume files; without
10662        // isolation, parallel tests and re-runs pollute one another. See
10663        // feedback_irontide_resume_test_isolation memory entry.
10664        let resume_dir = tempfile::tempdir().unwrap();
10665        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10666            .await
10667            .unwrap();
10668
10669        let magnet_uri = format!(
10670            "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
10671            info_hash.to_hex()
10672        );
10673        let added = session
10674            .add_torrent(AddTorrentParams::magnet(magnet_uri))
10675            .await
10676            .unwrap();
10677        assert_eq!(
10678            added, info_hash,
10679            "magnet info hash must equal synth info hash"
10680        );
10681
10682        // The synchronous contract: when `debug_inject_metadata` returns
10683        // Ok, the metadata must already be visible via `torrent_file` — no
10684        // polling, no sleep. This is what distinguishes it from the M147
10685        // fire-and-forget path.
10686        session
10687            .debug_inject_metadata(info_hash, info_bytes)
10688            .await
10689            .expect("debug_inject_metadata must succeed");
10690
10691        let meta = session
10692            .torrent_file(info_hash)
10693            .await
10694            .expect("torrent_file call")
10695            .expect("metadata must be present immediately after sync inject");
10696        assert_eq!(meta.info_hash, info_hash);
10697
10698        session.shutdown().await.unwrap();
10699    }
10700
10701    #[cfg(feature = "test-util")]
10702    #[tokio::test]
10703    async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
10704        let resume_dir = tempfile::tempdir().unwrap();
10705        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10706            .await
10707            .unwrap();
10708
10709        let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
10710        let result = session.debug_inject_metadata(bogus, vec![]).await;
10711        assert!(
10712            matches!(result, Err(crate::Error::TorrentNotFound(_))),
10713            "expected TorrentNotFound for unknown hash; got {result:?}"
10714        );
10715
10716        session.shutdown().await.unwrap();
10717    }
10718
10719    // ---- v0.173.2 T7: A11 ssl_cert metadata propagation regression ----
10720
10721    /// Synthesise a v1 info dict with optional `private` and `ssl-cert`
10722    /// fields. Unlike [`make_debug_inject_info`], this helper exists
10723    /// specifically to exercise the BEP 35 `ssl-cert` propagation path
10724    /// from the bencoded info dict into `TorrentMetaV1::info.ssl_cert`
10725    /// after synchronous metadata injection (T2's `debug_inject_metadata`).
10726    ///
10727    /// The bencode key name is `ssl-cert` (with hyphen) per BEP 35; serde
10728    /// renames it on the synthesised `Info` struct below so the emitted
10729    /// bytes match `InfoDict`'s deserialiser exactly.
10730    ///
10731    /// The caller hashes the returned bytes directly to compute the info
10732    /// hash — the returned bytes are the info dict alone (no wrapper).
10733    #[cfg(feature = "test-util")]
10734    fn build_synth_info_bytes_with_options(
10735        name: &str,
10736        length_bytes: u64,
10737        piece_length: u64,
10738        private: Option<i64>,
10739        ssl_cert: Option<Vec<u8>>,
10740    ) -> Vec<u8> {
10741        use serde::Serialize;
10742
10743        #[derive(Serialize)]
10744        struct Info {
10745            length: u64,
10746            name: String,
10747            #[serde(rename = "piece length")]
10748            piece_length: u64,
10749            pieces: serde_bytes::ByteBuf,
10750            #[serde(skip_serializing_if = "Option::is_none")]
10751            private: Option<i64>,
10752            #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
10753            ssl_cert: Option<serde_bytes::ByteBuf>,
10754        }
10755
10756        // pieces = SHA-1 of an all-zero piece, repeated. The injected data
10757        // is never verified against disk during these tests — we only care
10758        // that the bencoded info dict round-trips through the session's
10759        // metadata-resolution path with ssl_cert intact.
10760        let num_pieces = length_bytes.div_ceil(piece_length);
10761        let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
10762        let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
10763        for _ in 0..num_pieces {
10764            pieces.extend_from_slice(zero_piece_hash.as_bytes());
10765        }
10766
10767        let info = Info {
10768            length: length_bytes,
10769            name: name.to_owned(),
10770            piece_length,
10771            pieces: serde_bytes::ByteBuf::from(pieces),
10772            private,
10773            ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
10774        };
10775        irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
10776    }
10777
10778    #[cfg(feature = "test-util")]
10779    #[tokio::test]
10780    async fn ssl_cert_propagates_to_meta_after_inject() {
10781        use crate::session::AddTorrentParams;
10782
10783        let resume_dir = tempfile::tempdir().unwrap();
10784        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10785            .await
10786            .unwrap();
10787
10788        let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
10789        let info_bytes = build_synth_info_bytes_with_options(
10790            "ssl-fixture",
10791            16_384,
10792            16_384,
10793            None,
10794            Some(cert_pem.clone()),
10795        );
10796        let info_hash = irontide_core::sha1(&info_bytes);
10797
10798        let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
10799        let added = session
10800            .add_torrent(AddTorrentParams::magnet(magnet))
10801            .await
10802            .unwrap();
10803        assert_eq!(
10804            added, info_hash,
10805            "magnet info hash must equal synth info hash"
10806        );
10807
10808        session
10809            .debug_inject_metadata(info_hash, info_bytes)
10810            .await
10811            .expect("debug_inject_metadata must succeed");
10812
10813        let meta = session
10814            .torrent_file(info_hash)
10815            .await
10816            .expect("torrent_file Ok")
10817            .expect("metadata must be present immediately after sync inject");
10818        assert_eq!(
10819            meta.info.ssl_cert.as_ref(),
10820            Some(&cert_pem),
10821            "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
10822        );
10823
10824        session.shutdown().await.unwrap();
10825    }
10826
10827    #[cfg(feature = "test-util")]
10828    #[tokio::test]
10829    async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
10830        use crate::session::AddTorrentParams;
10831
10832        let resume_dir = tempfile::tempdir().unwrap();
10833        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10834            .await
10835            .unwrap();
10836
10837        let info_bytes =
10838            build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
10839        let info_hash = irontide_core::sha1(&info_bytes);
10840
10841        let magnet = format!(
10842            "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
10843            info_hash.to_hex()
10844        );
10845        let added = session
10846            .add_torrent(AddTorrentParams::magnet(magnet))
10847            .await
10848            .unwrap();
10849        assert_eq!(
10850            added, info_hash,
10851            "magnet info hash must equal synth info hash"
10852        );
10853
10854        session
10855            .debug_inject_metadata(info_hash, info_bytes)
10856            .await
10857            .expect("debug_inject_metadata must succeed");
10858
10859        let meta = session
10860            .torrent_file(info_hash)
10861            .await
10862            .expect("torrent_file Ok")
10863            .expect("metadata must be present immediately after sync inject");
10864        assert!(
10865            meta.info.ssl_cert.is_none(),
10866            "absent ssl-cert in info dict must remain None in meta; got {:?}",
10867            meta.info.ssl_cert
10868        );
10869
10870        session.shutdown().await.unwrap();
10871    }
10872
10873    // ---- P2C4: Startup init throttle tests ----
10874
10875    #[tokio::test]
10876    async fn init_throttle_queues_restored_torrents() {
10877        let tmp = tempfile::TempDir::new().unwrap();
10878        let resume_dir = tmp.path().to_path_buf();
10879
10880        // Phase 1: create a session, add 5 torrents, save resume data.
10881        {
10882            let mut settings = resume_test_settings(&resume_dir);
10883            settings.queueing_enabled = false;
10884            let session = SessionHandle::start(settings).await.unwrap();
10885            for i in 0u8..5 {
10886                let data = vec![i.wrapping_add(0xA0); 16384];
10887                let meta = make_test_torrent(&data, 16384);
10888                let storage = make_storage(&data, 16384);
10889                session
10890                    .add_torrent_with_meta(meta.into(), Some(storage))
10891                    .await
10892                    .unwrap();
10893            }
10894            tokio::time::sleep(Duration::from_millis(100)).await;
10895            let saved = session.save_resume_state().await.unwrap();
10896            assert!(saved >= 3, "should save most resume files, got {saved}");
10897            session.shutdown().await.unwrap();
10898        }
10899
10900        // Phase 2: restart with queueing_enabled and active_checking=2.
10901        {
10902            let mut settings = resume_test_settings(&resume_dir);
10903            settings.queueing_enabled = true;
10904            settings.active_checking = 2;
10905            settings.active_downloads = 2;
10906            settings.active_seeds = 2;
10907            settings.active_limit = 4;
10908            let session = SessionHandle::start(settings).await.unwrap();
10909            tokio::time::sleep(Duration::from_millis(200)).await;
10910
10911            let list = session.list_torrent_summaries().await.unwrap();
10912            let queued = list
10913                .iter()
10914                .filter(|t| t.state == TorrentState::Queued)
10915                .count();
10916            let active = list
10917                .iter()
10918                .filter(|t| t.state != TorrentState::Queued)
10919                .count();
10920
10921            assert!(
10922                queued > 0,
10923                "at least one torrent should be Queued, but all {active} are active"
10924            );
10925            assert!(
10926                active <= 4,
10927                "active torrents ({active}) should not exceed active_limit (4)"
10928            );
10929            session.shutdown().await.unwrap();
10930        }
10931    }
10932
10933    #[tokio::test]
10934    async fn init_throttle_disabled_restores_all_immediately() {
10935        let tmp = tempfile::TempDir::new().unwrap();
10936        let resume_dir = tmp.path().to_path_buf();
10937
10938        // Phase 1: add torrents, save resume.
10939        {
10940            let settings = resume_test_settings(&resume_dir);
10941            let session = SessionHandle::start(settings).await.unwrap();
10942            for i in 0u8..3 {
10943                let data = vec![i.wrapping_add(0xC0); 16384];
10944                let meta = make_test_torrent(&data, 16384);
10945                let storage = make_storage(&data, 16384);
10946                session
10947                    .add_torrent_with_meta(meta.into(), Some(storage))
10948                    .await
10949                    .unwrap();
10950            }
10951            tokio::time::sleep(Duration::from_millis(100)).await;
10952            session.save_resume_state().await.unwrap();
10953            session.shutdown().await.unwrap();
10954        }
10955
10956        // Phase 2: restart with queueing DISABLED.
10957        {
10958            let mut settings = resume_test_settings(&resume_dir);
10959            settings.queueing_enabled = false;
10960            let session = SessionHandle::start(settings).await.unwrap();
10961            tokio::time::sleep(Duration::from_millis(200)).await;
10962
10963            let list = session.list_torrent_summaries().await.unwrap();
10964            let queued = list
10965                .iter()
10966                .filter(|t| t.state == TorrentState::Queued)
10967                .count();
10968            assert_eq!(
10969                queued, 0,
10970                "with queueing disabled, no torrents should be Queued"
10971            );
10972            session.shutdown().await.unwrap();
10973        }
10974    }
10975
10976    #[tokio::test]
10977    async fn checking_complete_triggers_immediate_eval() {
10978        use crate::alert::AlertKind;
10979
10980        let mut settings = test_settings();
10981        settings.queueing_enabled = true;
10982        settings.active_checking = 1;
10983        settings.active_downloads = 5;
10984        settings.active_seeds = 5;
10985        settings.active_limit = 10;
10986        settings.auto_manage_interval = 300;
10987        let session = SessionHandle::start(settings).await.unwrap();
10988        let mut alerts = session.subscribe();
10989
10990        // Add 3 small torrents with correct data so checking completes.
10991        let mut hashes = Vec::new();
10992        for i in 0u8..3 {
10993            let data = vec![i.wrapping_add(0xD0); 16384];
10994            let meta = make_test_torrent(&data, 16384);
10995            let storage = make_storage(&data, 16384);
10996            let h = session
10997                .add_torrent_with_meta(meta.into(), Some(storage))
10998                .await
10999                .unwrap();
11000            hashes.push(h);
11001        }
11002
11003        // Wait for at least one checking-complete state change. The trigger
11004        // should cause evaluate_queue to promote the next candidate without
11005        // waiting for the 300s auto_manage_interval.
11006        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11007        let mut saw_checking_transition = false;
11008        while tokio::time::Instant::now() < deadline {
11009            if let Ok(Ok(alert)) =
11010                tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11011                && matches!(
11012                    alert.kind,
11013                    AlertKind::StateChanged {
11014                        prev_state: TorrentState::Checking,
11015                        ..
11016                    }
11017                )
11018            {
11019                saw_checking_transition = true;
11020                break;
11021            }
11022        }
11023
11024        assert!(
11025            saw_checking_transition,
11026            "should have seen a Checking→* state transition"
11027        );
11028
11029        // After the checking-complete trigger, the evaluator should have
11030        // promoted another torrent. Give a moment for the evaluate_queue
11031        // triggered by the alert arm to run.
11032        tokio::time::sleep(Duration::from_millis(200)).await;
11033
11034        let list = session.list_torrent_summaries().await.unwrap();
11035        let active = list
11036            .iter()
11037            .filter(|t| t.state != TorrentState::Queued)
11038            .count();
11039        assert!(
11040            active >= 1,
11041            "at least one torrent should be active after checking-complete trigger"
11042        );
11043
11044        session.shutdown().await.unwrap();
11045    }
11046
11047    // ---- P2C5: Restore queue position from resume data tests ----
11048
11049    #[tokio::test]
11050    async fn resume_restores_queue_position() {
11051        let tmp = tempfile::TempDir::new().unwrap();
11052        let resume_dir = tmp.path().to_path_buf();
11053
11054        let data = vec![0xF0; 16384];
11055        let meta = make_test_torrent(&data, 16384);
11056        let info_hash = meta.info_hash;
11057
11058        // Phase 1: add torrent, set queue position, save resume.
11059        {
11060            let settings = resume_test_settings(&resume_dir);
11061            let session = SessionHandle::start(settings).await.unwrap();
11062            let storage = make_storage(&data, 16384);
11063            session
11064                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11065                .await
11066                .unwrap();
11067            session.set_queue_position(info_hash, 3).await.unwrap();
11068            tokio::time::sleep(Duration::from_millis(100)).await;
11069            session.save_resume_state().await.unwrap();
11070            session.shutdown().await.unwrap();
11071        }
11072
11073        // Phase 2: restart and verify position survived.
11074        {
11075            let settings = resume_test_settings(&resume_dir);
11076            let session = SessionHandle::start(settings).await.unwrap();
11077            tokio::time::sleep(Duration::from_millis(200)).await;
11078
11079            let pos = session.queue_position(info_hash).await.unwrap();
11080            // Renormalization reassigns 0..N-1; with a single torrent
11081            // the position is 0 regardless of saved value.
11082            assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11083            session.shutdown().await.unwrap();
11084        }
11085    }
11086
11087    #[tokio::test]
11088    async fn resume_restores_auto_managed_false() {
11089        let tmp = tempfile::TempDir::new().unwrap();
11090        let resume_dir = tmp.path().to_path_buf();
11091
11092        let data = vec![0xF1; 16384];
11093        let meta = make_test_torrent(&data, 16384);
11094        let info_hash = meta.info_hash;
11095
11096        // Phase 1: add torrent, disable auto-manage, save resume.
11097        {
11098            let settings = resume_test_settings(&resume_dir);
11099            let session = SessionHandle::start(settings).await.unwrap();
11100            let storage = make_storage(&data, 16384);
11101            session
11102                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11103                .await
11104                .unwrap();
11105            // Currently there's no direct API to set auto_managed on
11106            // SessionHandle — it's internal to TorrentEntry. Verify that
11107            // the resume round-trip preserves the default (true → 1).
11108            tokio::time::sleep(Duration::from_millis(100)).await;
11109            session.save_resume_state().await.unwrap();
11110            session.shutdown().await.unwrap();
11111        }
11112
11113        // Manually patch the resume file to set auto_managed=0.
11114        {
11115            let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11116            if path.exists() {
11117                let bytes = std::fs::read(&path).unwrap();
11118                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11119                rd.auto_managed = 0;
11120                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11121                std::fs::write(&path, patched).unwrap();
11122            }
11123        }
11124
11125        // Phase 2: restart and verify auto_managed was restored as false.
11126        {
11127            let settings = resume_test_settings(&resume_dir);
11128            let session = SessionHandle::start(settings).await.unwrap();
11129            tokio::time::sleep(Duration::from_millis(200)).await;
11130
11131            let stats = session.torrent_stats(info_hash).await.unwrap();
11132            assert!(
11133                !stats.auto_managed,
11134                "auto_managed should be false after restore"
11135            );
11136            session.shutdown().await.unwrap();
11137        }
11138    }
11139
11140    #[tokio::test]
11141    async fn resume_renormalizes_duplicate_positions() {
11142        let tmp = tempfile::TempDir::new().unwrap();
11143        let resume_dir = tmp.path().to_path_buf();
11144
11145        // Phase 1: add 3 torrents, save resume.
11146        let mut hashes = Vec::new();
11147        {
11148            let settings = resume_test_settings(&resume_dir);
11149            let session = SessionHandle::start(settings).await.unwrap();
11150            for i in 0u8..3 {
11151                let data = vec![i.wrapping_add(0xE0); 16384];
11152                let meta = make_test_torrent(&data, 16384);
11153                let storage = make_storage(&data, 16384);
11154                let h = session
11155                    .add_torrent_with_meta(meta.into(), Some(storage))
11156                    .await
11157                    .unwrap();
11158                hashes.push(h);
11159            }
11160            tokio::time::sleep(Duration::from_millis(100)).await;
11161            session.save_resume_state().await.unwrap();
11162            session.shutdown().await.unwrap();
11163        }
11164
11165        // Manually patch ALL resume files to have queue_position=0.
11166        for hash in &hashes {
11167            let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11168            if path.exists() {
11169                let bytes = std::fs::read(&path).unwrap();
11170                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11171                rd.queue_position = 0;
11172                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11173                std::fs::write(&path, patched).unwrap();
11174            }
11175        }
11176
11177        // Phase 2: restart and verify positions are contiguous 0,1,2.
11178        {
11179            let settings = resume_test_settings(&resume_dir);
11180            let session = SessionHandle::start(settings).await.unwrap();
11181            tokio::time::sleep(Duration::from_millis(200)).await;
11182
11183            let mut positions = Vec::new();
11184            for hash in &hashes {
11185                if let Ok(pos) = session.queue_position(*hash).await {
11186                    positions.push(pos);
11187                }
11188            }
11189            positions.sort_unstable();
11190            let expected: Vec<i32> = (0..positions.len() as i32).collect();
11191            assert_eq!(
11192                positions, expected,
11193                "positions should be contiguous 0..N-1 after renormalization"
11194            );
11195            session.shutdown().await.unwrap();
11196        }
11197    }
11198
11199    // ---- P2C6: EWMA rate smoothing tests ----
11200
11201    #[test]
11202    fn ewma_smooths_transient_drop() {
11203        let alpha = 0.3_f64;
11204        let prev = 100_000.0_f64;
11205        let sample = 0.0_f64;
11206        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11207        assert!(
11208            (smoothed - 70_000.0).abs() < 1.0,
11209            "smoothed rate should be ~70000, got {smoothed}"
11210        );
11211    }
11212
11213    #[test]
11214    fn ewma_alpha_one_equals_raw() {
11215        let alpha = 1.0_f64;
11216        let prev = 100_000.0_f64;
11217        let sample = 42_000.0_f64;
11218        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11219        assert!(
11220            (smoothed - sample).abs() < 0.001,
11221            "alpha=1.0 should produce raw rate, got {smoothed}"
11222        );
11223    }
11224
11225    // ---- P2C7: Configurable seed anti-flap duration tests ----
11226
11227    #[test]
11228    fn seed_anti_flap_uses_longer_duration() {
11229        let seed_queue_min_active_secs = 1800_u64;
11230        let auto_manage_startup = 60_u64;
11231        let started_5_min_ago = std::time::Duration::from_mins(5);
11232        let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11233
11234        // Seeding torrent started 5 min ago: still recently_started
11235        // with seed_queue_min_active_secs=1800.
11236        assert!(
11237            started_5_min_ago < seed_duration,
11238            "5 min < 30 min, seeding torrent should be recently_started"
11239        );
11240
11241        // Downloading torrent started 5 min ago: NOT recently_started
11242        // with auto_manage_startup=60.
11243        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11244        assert!(
11245            started_5_min_ago > dl_duration,
11246            "5 min > 60s, downloading torrent should NOT be recently_started"
11247        );
11248    }
11249
11250    #[test]
11251    fn download_anti_flap_uses_startup_duration() {
11252        let auto_manage_startup = 60_u64;
11253        let started_5_min_ago = std::time::Duration::from_mins(5);
11254        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11255        assert!(
11256            started_5_min_ago > dl_duration,
11257            "downloading torrent started 5 min ago should NOT be recently_started"
11258        );
11259    }
11260
11261    // ── M214: classify round-trip for Connection + Speed fields ──────
11262
11263    #[test]
11264    fn classify_restart_required_upnp_change() {
11265        let old = Settings::default();
11266        let mut new = old.clone();
11267        new.enable_upnp = !old.enable_upnp;
11268        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11269        assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11270    }
11271
11272    #[test]
11273    fn classify_restart_required_natpmp_change() {
11274        let old = Settings::default();
11275        let mut new = old.clone();
11276        new.enable_natpmp = !old.enable_natpmp;
11277        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11278        assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11279    }
11280
11281    #[test]
11282    fn classify_immediate_max_connec_global_change() {
11283        let old = Settings::default();
11284        let mut new = old.clone();
11285        new.max_connections_global = if old.max_connections_global == 500 {
11286            501
11287        } else {
11288            500
11289        };
11290        assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11291        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11292    }
11293
11294    #[test]
11295    fn classify_immediate_max_uploads_per_torrent_change() {
11296        // M224: per-torrent upload slot cap is classified immediate; the
11297        // choker reads the new cap at its next unchoke tick. Mirrors the
11298        // max_connec_global pattern from M214.
11299        let old = Settings::default();
11300        let mut new = old.clone();
11301        new.max_uploads_per_torrent = 4;
11302        assert_eq!(
11303            classify_immediate(&old, &new),
11304            vec!["max_uploads_per_torrent"]
11305        );
11306        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11307    }
11308
11309    #[test]
11310    fn classify_restart_required_proxy_type_change() {
11311        let old = Settings::default();
11312        let mut new = old.clone();
11313        new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11314        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11315        assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11316    }
11317
11318    #[test]
11319    fn classify_restart_required_proxy_credentials_change() {
11320        let old = Settings::default();
11321        let mut new = old.clone();
11322        new.proxy.username = Some("alice".into());
11323        new.proxy.password = Some("secret".into());
11324        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11325        let restart = classify_restart_required(&old, &new);
11326        // Both fields must surface; order is implementation-defined but the
11327        // set must equal {proxy_username, proxy_password}.
11328        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11329        assert_eq!(
11330            set,
11331            ["proxy_username", "proxy_password"]
11332                .into_iter()
11333                .collect::<std::collections::HashSet<_>>()
11334        );
11335    }
11336
11337    #[test]
11338    fn classify_combined_immediate_and_restart() {
11339        // Multi-field diff: max_connec_global + max_uploads_per_torrent
11340        // (both immediate) + upnp + proxy_type (both restart) should populate
11341        // both lists. M224 extends the immediate side.
11342        let old = Settings::default();
11343        let mut new = old.clone();
11344        new.max_connections_global = old.max_connections_global + 1;
11345        new.max_uploads_per_torrent = 4;
11346        new.enable_upnp = !old.enable_upnp;
11347        new.proxy.proxy_type = crate::proxy::ProxyType::Http;
11348
11349        let immediate = classify_immediate(&old, &new);
11350        let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
11351        assert_eq!(
11352            imm_set,
11353            ["max_connec_global", "max_uploads_per_torrent"]
11354                .into_iter()
11355                .collect::<std::collections::HashSet<_>>()
11356        );
11357        let restart = classify_restart_required(&old, &new);
11358        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11359        assert_eq!(
11360            set,
11361            ["upnp", "proxy_type"]
11362                .into_iter()
11363                .collect::<std::collections::HashSet<_>>()
11364        );
11365    }
11366
11367    // ── M215: BitTorrent + Advanced classify-list verification ──────
11368
11369    #[test]
11370    fn classify_immediate_seed_time_limit_change() {
11371        let old = Settings::default();
11372        let mut new = old.clone();
11373        new.seed_time_limit_secs = Some(3600);
11374        assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
11375        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11376    }
11377
11378    #[test]
11379    fn classify_immediate_inactive_seed_time_limit_change() {
11380        let old = Settings::default();
11381        let mut new = old.clone();
11382        new.inactive_seed_time_limit_secs = Some(1800);
11383        assert_eq!(
11384            classify_immediate(&old, &new),
11385            vec!["max_inactive_seeding_time"]
11386        );
11387        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11388    }
11389
11390    // M225: `classify_restart_required_hashing_threads_change` and
11391    // `classify_restart_required_save_resume_interval_change` (formerly here)
11392    // were DELETED in M225 — those fields are now `classify_immediate` per
11393    // OV F2c / F4. Replacement immediate-dispatch coverage follows.
11394
11395    #[test]
11396    fn classify_immediate_save_resume_interval_change() {
11397        // M225 Step 1: save_resume_interval graduated from restart_required
11398        // to immediate. SessionActor rebuilds the resume_save_interval timer
11399        // via Arc<Notify> on dispatch — no daemon restart needed.
11400        let old = Settings::default();
11401        let mut new = old.clone();
11402        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
11403        assert_eq!(
11404            classify_immediate(&old, &new),
11405            vec!["save_resume_interval"]
11406        );
11407        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11408    }
11409
11410    #[test]
11411    fn classify_immediate_hashing_threads_change() {
11412        // M225 Step 2: hashing_threads graduated from restart_required to
11413        // immediate. The per-torrent piece-verify batch reads
11414        // self.config.hashing_threads at the start of each batch, so a value
11415        // change applies on the NEXT batch via the existing
11416        // TorrentCommand::UpdateSettings(SettingsDelta) fan-out path.
11417        let old = Settings::default();
11418        let mut new = old.clone();
11419        new.hashing_threads = old.hashing_threads.saturating_add(2);
11420        assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
11421        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11422    }
11423
11424    #[test]
11425    fn classify_immediate_ip_filter_enabled_change() {
11426        // M225 Step 3: ip_filter_enabled graduated to immediate. apply_settings
11427        // writes through Arc<RwLock<IpFilter>> with self.ip_filter.write().enabled
11428        // = enabled; future is_blocked calls observe the new value on the next
11429        // RwLock read.
11430        let old = Settings::default();
11431        let mut new = old.clone();
11432        new.ip_filter_enabled = !old.ip_filter_enabled;
11433        assert_eq!(
11434            classify_immediate(&old, &new),
11435            vec!["ip_filter_enabled"]
11436        );
11437        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11438    }
11439
11440    #[test]
11441    fn settings_delta_from_diff_includes_save_resume_interval() {
11442        // M225 Step 1: SettingsDelta carries save_resume_interval_secs so
11443        // apply_settings can dispatch the Notify on observed change.
11444        use crate::types::SettingsDelta;
11445        let old = Settings::default();
11446        let mut new = old.clone();
11447        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
11448        let d = SettingsDelta::from_diff(&old, &new);
11449        assert_eq!(d.save_resume_interval_secs, Some(new.save_resume_interval_secs));
11450        assert!(d.hashing_threads.is_none());
11451        assert!(d.ip_filter_enabled.is_none());
11452        assert!(!d.is_empty());
11453    }
11454
11455    #[test]
11456    fn settings_delta_from_diff_includes_hashing_threads() {
11457        // M225 Step 2: SettingsDelta carries hashing_threads so the existing
11458        // TorrentCommand::UpdateSettings fan-out propagates per-torrent.
11459        use crate::types::SettingsDelta;
11460        let old = Settings::default();
11461        let mut new = old.clone();
11462        new.hashing_threads = old.hashing_threads.saturating_add(1);
11463        let d = SettingsDelta::from_diff(&old, &new);
11464        assert_eq!(d.hashing_threads, Some(new.hashing_threads));
11465        assert!(d.save_resume_interval_secs.is_none());
11466        assert!(d.ip_filter_enabled.is_none());
11467        assert!(!d.is_empty());
11468    }
11469
11470    #[test]
11471    fn settings_delta_from_diff_includes_ip_filter_enabled() {
11472        // M225 Step 3: SettingsDelta carries ip_filter_enabled. apply_settings
11473        // applies it through the outer Arc<RwLock<IpFilter>> write-lock.
11474        use crate::types::SettingsDelta;
11475        let old = Settings::default();
11476        let mut new = old.clone();
11477        new.ip_filter_enabled = !old.ip_filter_enabled;
11478        let d = SettingsDelta::from_diff(&old, &new);
11479        assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
11480        assert!(d.save_resume_interval_secs.is_none());
11481        assert!(d.hashing_threads.is_none());
11482        assert!(!d.is_empty());
11483    }
11484
11485    #[test]
11486    fn settings_delta_is_empty_honours_m225_fields() {
11487        // M225: is_empty must return false when any M225 field is set so the
11488        // fan-out path runs.
11489        use crate::types::SettingsDelta;
11490        let mut d = SettingsDelta::default();
11491        assert!(d.is_empty());
11492        d.save_resume_interval_secs = Some(120);
11493        assert!(!d.is_empty());
11494        d = SettingsDelta::default();
11495        d.hashing_threads = Some(8);
11496        assert!(!d.is_empty());
11497        d = SettingsDelta::default();
11498        d.ip_filter_enabled = Some(false);
11499        assert!(!d.is_empty());
11500    }
11501
11502    // ── M226: SettingsDelta + classify_immediate coverage ──────────────────
11503
11504    /// Helper for compact M226 delta+classify tests: toggle one field, verify
11505    /// the delta picks it up AND [`classify_immediate`] yields the expected
11506    /// alias AND [`classify_restart_required`] stays empty.
11507    fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
11508    where
11509        F: FnOnce(&mut Settings),
11510    {
11511        use crate::types::SettingsDelta;
11512        let old = Settings::default();
11513        let mut new = old.clone();
11514        mutate(&mut new);
11515        let d = SettingsDelta::from_diff(&old, &new);
11516        assert!(!d.is_empty(), "{alias}: delta must not be empty after toggle");
11517        let imm = classify_immediate(&old, &new);
11518        assert!(
11519            imm.contains(&alias),
11520            "{alias}: classify_immediate must contain alias, got {imm:?}"
11521        );
11522        let rr = classify_restart_required(&old, &new);
11523        assert!(
11524            !rr.contains(&alias),
11525            "{alias}: must NOT appear in classify_restart_required"
11526        );
11527    }
11528
11529    #[test]
11530    fn m226_notify_on_complete_immediate() {
11531        m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
11532    }
11533
11534    #[test]
11535    fn m226_notify_on_error_immediate() {
11536        m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
11537    }
11538
11539    #[test]
11540    fn m226_on_complete_program_immediate() {
11541        m226_delta_and_classify_check(
11542            |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
11543            "on_complete_program",
11544        );
11545    }
11546
11547    #[test]
11548    fn m226_use_incomplete_dir_immediate() {
11549        m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
11550    }
11551
11552    #[test]
11553    fn m226_incomplete_dir_immediate() {
11554        m226_delta_and_classify_check(
11555            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
11556            "incomplete_dir",
11557        );
11558    }
11559
11560    #[test]
11561    fn m226_default_skip_hash_check_immediate() {
11562        m226_delta_and_classify_check(
11563            |s| s.default_skip_hash_check = true,
11564            "default_skip_hash_check",
11565        );
11566    }
11567
11568    #[test]
11569    fn m226_incomplete_extension_enabled_immediate() {
11570        // Default is TRUE so we toggle false to observe diff.
11571        m226_delta_and_classify_check(
11572            |s| s.incomplete_extension_enabled = false,
11573            "incomplete_extension_enabled",
11574        );
11575    }
11576
11577    #[test]
11578    fn m226_watched_folder_immediate() {
11579        m226_delta_and_classify_check(
11580            |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
11581            "watched_folder",
11582        );
11583    }
11584
11585    #[test]
11586    fn m226_delete_torrent_after_add_immediate() {
11587        m226_delta_and_classify_check(
11588            |s| s.delete_torrent_after_add = true,
11589            "delete_torrent_after_add",
11590        );
11591    }
11592
11593    #[test]
11594    fn m226_move_completed_enabled_immediate() {
11595        m226_delta_and_classify_check(
11596            |s| s.move_completed_enabled = true,
11597            "move_completed_enabled",
11598        );
11599    }
11600
11601    #[test]
11602    fn m226_move_completed_to_immediate() {
11603        m226_delta_and_classify_check(
11604            |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
11605            "move_completed_to",
11606        );
11607    }
11608
11609    #[test]
11610    fn m226_ip_filter_auto_refresh_immediate() {
11611        m226_delta_and_classify_check(
11612            |s| s.ip_filter_auto_refresh = true,
11613            "ip_filter_auto_refresh",
11614        );
11615    }
11616
11617    #[test]
11618    fn m226_web_ui_https_enabled_immediate() {
11619        m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
11620    }
11621
11622    #[test]
11623    fn m226_network_interface_immediate() {
11624        m226_delta_and_classify_check(
11625            |s| s.network_interface = Some("eth0".into()),
11626            "network_interface",
11627        );
11628    }
11629
11630    #[test]
11631    fn m226_default_add_paused_immediate() {
11632        m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
11633    }
11634
11635    #[test]
11636    fn m226_delta_clears_optional_path_incomplete_dir() {
11637        // F4 — outer Some + inner None means "clear to None". Without nested
11638        // Option this case is indistinguishable from "no change".
11639        use crate::types::SettingsDelta;
11640        let old = Settings {
11641            incomplete_dir: Some(std::path::PathBuf::from("/foo")),
11642            ..Settings::default()
11643        };
11644        let new = Settings {
11645            incomplete_dir: None,
11646            ..old.clone()
11647        };
11648        let d = SettingsDelta::from_diff(&old, &new);
11649        assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
11650        assert!(!d.is_empty());
11651    }
11652
11653    #[test]
11654    fn m226_delta_clears_optional_path_watched_folder() {
11655        // F4 — same pattern for watched_folder.
11656        use crate::types::SettingsDelta;
11657        let old = Settings {
11658            watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
11659            ..Settings::default()
11660        };
11661        let new = Settings {
11662            watched_folder: None,
11663            ..old.clone()
11664        };
11665        let d = SettingsDelta::from_diff(&old, &new);
11666        assert_eq!(d.watched_folder, Some(None));
11667        assert!(!d.is_empty());
11668    }
11669
11670    #[test]
11671    fn m226_delta_is_empty_honours_new_fields() {
11672        // is_empty must return false when ANY M226 field is set.
11673        use crate::types::SettingsDelta;
11674        let mut d = SettingsDelta::default();
11675        assert!(d.is_empty());
11676        d.notify_on_complete = Some(true);
11677        assert!(!d.is_empty());
11678        d = SettingsDelta::default();
11679        d.watched_folder = Some(None);  // clear-to-None still counts
11680        assert!(!d.is_empty());
11681        d = SettingsDelta::default();
11682        d.default_add_paused = Some(true);
11683        assert!(!d.is_empty());
11684    }
11685
11686    #[test]
11687    fn m226_no_fields_appear_in_restart_required() {
11688        // Negative coverage: toggling each of the 15 M226 fields one at a
11689        // time must NOT produce any classify_restart_required entries.
11690        type Mutation = fn(&mut Settings);
11691        let mutations: [Mutation; 15] = [
11692            |s| s.notify_on_complete = true,
11693            |s| s.notify_on_error = true,
11694            |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
11695            |s| s.use_incomplete_dir = true,
11696            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
11697            |s| s.default_skip_hash_check = true,
11698            |s| s.incomplete_extension_enabled = false,
11699            |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
11700            |s| s.delete_torrent_after_add = true,
11701            |s| s.move_completed_enabled = true,
11702            |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
11703            |s| s.ip_filter_auto_refresh = true,
11704            |s| s.web_ui_https_enabled = true,
11705            |s| s.network_interface = Some("eth0".into()),
11706            |s| s.default_add_paused = true,
11707        ];
11708        let old = Settings::default();
11709        for (idx, m) in mutations.iter().enumerate() {
11710            let mut new = old.clone();
11711            m(&mut new);
11712            let rr = classify_restart_required(&old, &new);
11713            assert!(
11714                rr.is_empty(),
11715                "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
11716            );
11717        }
11718    }
11719
11720    #[test]
11721    fn classify_immediate_seed_time_and_inactive_combined() {
11722        // Both seed-time limits flipped in one delta — both must surface
11723        // in `immediate`, none in `restart_required`.
11724        let old = Settings::default();
11725        let mut new = old.clone();
11726        new.seed_time_limit_secs = Some(7200);
11727        new.inactive_seed_time_limit_secs = Some(900);
11728        let imm = classify_immediate(&old, &new);
11729        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11730        assert_eq!(
11731            set,
11732            ["max_seeding_time", "max_inactive_seeding_time"]
11733                .into_iter()
11734                .collect::<std::collections::HashSet<_>>()
11735        );
11736        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11737    }
11738
11739    #[test]
11740    fn classify_combined_seed_time_and_hashing_both_immediate() {
11741        // M225: hashing_threads graduated from restart_required to immediate
11742        // (D-eng-2 revised). With seed_time_limit also immediate, both fields
11743        // must surface in immediate, none in restart_required.
11744        let old = Settings::default();
11745        let mut new = old.clone();
11746        new.seed_time_limit_secs = Some(1200);
11747        new.hashing_threads = old.hashing_threads.saturating_add(2);
11748        let imm = classify_immediate(&old, &new);
11749        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11750        assert_eq!(
11751            set,
11752            ["max_seeding_time", "hashing_threads"]
11753                .into_iter()
11754                .collect::<std::collections::HashSet<_>>()
11755        );
11756        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11757    }
11758
11759    #[test]
11760    fn classify_combined_hashing_and_save_resume_both_immediate() {
11761        // M225: hashing_threads and save_resume_interval both graduated from
11762        // restart_required to immediate. Mixed change must land both in
11763        // immediate, none in restart_required.
11764        let old = Settings::default();
11765        let mut new = old.clone();
11766        new.hashing_threads = old.hashing_threads.saturating_add(3);
11767        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
11768        let imm = classify_immediate(&old, &new);
11769        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11770        assert_eq!(
11771            set,
11772            ["hashing_threads", "save_resume_interval"]
11773                .into_iter()
11774                .collect::<std::collections::HashSet<_>>()
11775        );
11776        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11777    }
11778
11779    // ── M226: AddTorrentParams.paused: Option<bool> resolution ──────────
11780    //
11781    // The constructor default `paused: None` means "honour the engine's
11782    // `default_add_paused`"; an explicit `.paused(v)` call wraps `Some(v)`
11783    // and wins over the engine setting. Tests exercise all 4 combinations
11784    // (3 here + 1 implicit default already covered by existing
11785    // `pause_resume_via_session` and `add_torrent_with_meta` tests).
11786
11787    /// Re-bencode a single-piece v1 torrent so the bytes branch of
11788    /// `add_torrent` can ingest it. Helper avoids dragging
11789    /// `add_torrent_with_meta` into the M226 tests (that bypasses
11790    /// `AddTorrentParams` entirely).
11791    fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
11792        use serde::Serialize;
11793
11794        #[derive(Serialize)]
11795        struct Info<'a> {
11796            length: u64,
11797            name: &'a str,
11798            #[serde(rename = "piece length")]
11799            piece_length: u64,
11800            #[serde(with = "serde_bytes")]
11801            pieces: &'a [u8],
11802        }
11803        #[derive(Serialize)]
11804        struct Torrent<'a> {
11805            info: Info<'a>,
11806        }
11807
11808        let mut pieces = Vec::new();
11809        let mut offset = 0;
11810        while offset < data.len() {
11811            let end = (offset + piece_length as usize).min(data.len());
11812            let hash = irontide_core::sha1(&data[offset..end]);
11813            pieces.extend_from_slice(hash.as_bytes());
11814            offset = end;
11815        }
11816
11817        irontide_bencode::to_bytes(&Torrent {
11818            info: Info {
11819                length: data.len() as u64,
11820                name: "m226-test",
11821                piece_length,
11822                pieces: &pieces,
11823            },
11824        })
11825        .unwrap()
11826    }
11827
11828    /// M226 D1 acceptance: `default_add_paused = true` + caller passes no
11829    /// explicit `.paused(...)` → torrent must land paused.
11830    #[tokio::test]
11831    async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
11832        let mut settings = test_settings();
11833        settings.default_add_paused = true;
11834        let session = SessionHandle::start(settings).await.unwrap();
11835
11836        let data = vec![0xAB; 16384];
11837        let bytes = m226_make_torrent_bytes(&data, 16384);
11838        let info_hash = session
11839            .add_torrent(AddTorrentParams::bytes(bytes))
11840            .await
11841            .unwrap();
11842
11843        // Pause is dispatched via `tokio::spawn(handle.pause())`; give the
11844        // dispatched task a tick to land before we read state.
11845        tokio::time::sleep(Duration::from_millis(100)).await;
11846        let stats = session.torrent_stats(info_hash).await.unwrap();
11847        assert_eq!(
11848            stats.state,
11849            TorrentState::Paused,
11850            "engine default_add_paused=true must pause the torrent when caller \
11851             passes AddTorrentParams::bytes() without an explicit .paused(...)"
11852        );
11853
11854        session.shutdown().await.unwrap();
11855    }
11856
11857    /// M226 D1 acceptance: explicit `.paused(false)` must beat
11858    /// `default_add_paused = true` — the engine setting is the fallback,
11859    /// the per-call override is authoritative.
11860    #[tokio::test]
11861    async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
11862        let mut settings = test_settings();
11863        settings.default_add_paused = true;
11864        let session = SessionHandle::start(settings).await.unwrap();
11865
11866        let data = vec![0xCD; 16384];
11867        let bytes = m226_make_torrent_bytes(&data, 16384);
11868        let info_hash = session
11869            .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
11870            .await
11871            .unwrap();
11872
11873        // Negative assertion: nothing should run a paused dispatch path —
11874        // a brief sleep guards against a phantom spawned pause.
11875        tokio::time::sleep(Duration::from_millis(100)).await;
11876        let stats = session.torrent_stats(info_hash).await.unwrap();
11877        assert_ne!(
11878            stats.state,
11879            TorrentState::Paused,
11880            "explicit .paused(false) must override default_add_paused=true; \
11881             got state={:?}",
11882            stats.state
11883        );
11884
11885        session.shutdown().await.unwrap();
11886    }
11887
11888    /// M226 D1 acceptance: explicit `.paused(true)` must beat
11889    /// `default_add_paused = false` — mirror image of the previous test
11890    /// to cover the other direction of "explicit wins over default".
11891    #[tokio::test]
11892    async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
11893        let mut settings = test_settings();
11894        settings.default_add_paused = false;
11895        let session = SessionHandle::start(settings).await.unwrap();
11896
11897        let data = vec![0xEF; 16384];
11898        let bytes = m226_make_torrent_bytes(&data, 16384);
11899        let info_hash = session
11900            .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
11901            .await
11902            .unwrap();
11903
11904        tokio::time::sleep(Duration::from_millis(100)).await;
11905        let stats = session.torrent_stats(info_hash).await.unwrap();
11906        assert_eq!(
11907            stats.state,
11908            TorrentState::Paused,
11909            "explicit .paused(true) must pause even when \
11910             default_add_paused=false"
11911        );
11912
11913        session.shutdown().await.unwrap();
11914    }
11915}