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_convert::SettingsConvertExt;
32use crate::torrent::TorrentHandle;
33use crate::types::{
34    FileInfo, SessionStats, TorrentConfig, TorrentInfo, TorrentState, TorrentStats, TorrentSummary,
35};
36use irontide_settings::Settings;
37
38/// Shared global rate limiter bucket.
39type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
40
41/// Function signature for queue move operations (`move_up`, `move_down`, etc.).
42type QueueMoveFn = fn(&mut [crate::queue::QueueEntry], Id20) -> Vec<(Id20, i32, i32)>;
43
44// SharedBanManager / SharedIpFilter relocated to irontide-session-types at M244a
45// (torrent→session back-edge break); imported here for session-local use.
46use irontide_session_types::{SharedBanManager, SharedIpFilter};
47
48/// Result of loading resume state from disk (M161 Phase 4).
49#[derive(Debug, Clone)]
50pub struct ResumeLoadResult {
51    /// Number of torrents successfully restored.
52    pub restored: usize,
53    /// Number of resume files skipped (duplicate, already exists).
54    pub skipped: usize,
55    /// Number of resume files that failed to load.
56    pub failed: usize,
57}
58
59/// Source for a torrent add (M170).
60///
61/// Separated from [`AddTorrentParams`] so that the builder API can name
62/// the source independently of the other knobs.
63#[derive(Debug, Clone)]
64pub enum AddSource {
65    /// Magnet URI (BEP 9 metadata fetch required post-add).
66    Magnet(String),
67    /// Raw `.torrent` file bytes (auto-detects v1/v2/hybrid).
68    Bytes(Vec<u8>),
69}
70
71/// Unified parameters for [`SessionHandle::add_torrent`] (M170).
72///
73/// Replaces the ad-hoc set of `add_magnet`, `add_magnet_uri`,
74/// `add_torrent_bytes` call shapes with a single params struct. Callers
75/// build the struct via the static constructors ([`magnet`] or [`bytes`])
76/// and chain `.with_category()` / `.with_tags()` / `.with_download_dir()`
77/// / `.paused()`.
78///
79/// Download-dir resolution (see `add_torrent`):
80/// 1. `download_dir: Some(p)` wins, if set.
81/// 2. Else, if `category: Some(name)` and the registry contains `name`,
82///    the registry's `save_path` is used.
83/// 3. Else, if `category: Some(name)` and the registry does NOT contain
84///    `name`, the call fails with [`Error::CategoryNotFound`].
85/// 4. Else, falls back to `Settings.download_dir`.
86///
87/// `skip_checking` is reserved for M171+ (qBt `skip_hash_check=true`).
88///
89/// [`magnet`]: Self::magnet
90/// [`bytes`]: Self::bytes
91#[derive(Debug, Clone)]
92pub struct AddTorrentParams {
93    /// The torrent source (magnet URI or raw .torrent bytes).
94    pub source: AddSource,
95    /// Optional qBt-compat category label resolved via the session's
96    /// [`CategoryRegistry`](crate::CategoryRegistry) at add-time.
97    pub category: Option<String>,
98    /// M171: Per-torrent tags baked in at add time (qBt-compat). Multi-
99    /// valued. An empty vec means "no tags" and is the default.
100    pub tags: Vec<String>,
101    /// Explicit download directory, overrides both category lookup and
102    /// `Settings.download_dir` when `Some`.
103    pub download_dir: Option<PathBuf>,
104    /// Whether the torrent should be added in a paused state. `None` (the
105    /// constructor default) means "use [`Settings::default_add_paused`]";
106    /// `Some(v)` is an explicit per-call override. The resolution happens
107    /// in `dispatch_add_torrent_m170` before the M170 post-add hooks run,
108    /// so the actor sees a concrete `bool` either way.
109    pub paused: Option<bool>,
110    /// Reserved for M171+ — skip the initial re-hash on add.
111    pub skip_checking: bool,
112}
113
114impl AddTorrentParams {
115    /// Build a magnet-source add with default knobs.
116    #[must_use]
117    pub fn magnet(uri: impl Into<String>) -> Self {
118        Self {
119            source: AddSource::Magnet(uri.into()),
120            category: None,
121            tags: Vec::new(),
122            download_dir: None,
123            paused: None,
124            skip_checking: false,
125        }
126    }
127
128    /// Build a bytes-source add with default knobs.
129    #[must_use]
130    pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
131        Self {
132            source: AddSource::Bytes(data.into()),
133            category: None,
134            tags: Vec::new(),
135            download_dir: None,
136            paused: None,
137            skip_checking: false,
138        }
139    }
140
141    /// Assign a category label to the torrent. The session resolves the
142    /// name against its registry at add-time; unknown names error out.
143    #[must_use]
144    pub fn with_category(mut self, name: impl Into<String>) -> Self {
145        self.category = Some(name.into());
146        self
147    }
148
149    /// M171: Attach tags at add time. Tags are baked into the torrent's
150    /// config before `TorrentActor::new`, so the first `stats()` call
151    /// returns them — no post-add spawn race.
152    #[must_use]
153    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
154        self.tags = tags;
155        self
156    }
157
158    /// Override the download directory for this torrent.
159    #[must_use]
160    pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
161        self.download_dir = Some(dir.into());
162        self
163    }
164
165    /// Toggle the paused-at-add flag. Wraps the explicit choice in `Some(_)`
166    /// so the actor can distinguish "caller explicitly set this" from
167    /// "caller did not touch it" (the latter falls back to
168    /// [`Settings::default_add_paused`]).
169    #[must_use]
170    pub fn paused(mut self, paused: bool) -> Self {
171        self.paused = Some(paused);
172        self
173    }
174
175    /// Toggle the skip-initial-check flag (M171+).
176    #[must_use]
177    pub fn skip_checking(mut self, skip: bool) -> Self {
178        self.skip_checking = skip;
179        self
180    }
181}
182
183/// Entry for a torrent managed by the session.
184///
185/// v0.173.1: the `meta: Option<TorrentMetaV1>` field was deleted. It was a
186/// stale cache that was silently `None` forever for magnet-added torrents —
187/// see the Class A archaeology in
188/// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`. Four reader
189/// sites (`handle_torrent_info`, `handle_remove_torrent_with_files`, `is_private`,
190/// `handle_ssl_incoming`) now query the `TorrentActor` via `handle.get_meta()`,
191/// which is the single source of truth for torrent metadata.
192struct TorrentEntry {
193    handle: TorrentHandle,
194    /// Queue position (-1 = not queued / not auto-managed).
195    queue_position: i32,
196    /// Whether the queue system controls this torrent.
197    auto_managed: bool,
198    /// When the torrent was last started/resumed (for startup grace period).
199    started_at: Option<tokio::time::Instant>,
200    /// EWMA-smoothed download rate for queue inactive classification.
201    smoothed_download_rate: f64,
202    /// EWMA-smoothed upload rate for queue inactive classification.
203    smoothed_upload_rate: f64,
204}
205
206/// M223 — off-actor add-torrent result bundle.
207///
208/// `handle_add_torrent` (and the M170 path) split into two phases:
209/// 1. **Prepare** — disk register, actor spawn. Runs in a `tokio::spawn`
210///    task off the `SessionActor` recv loop, so concurrent adds do not
211///    serialise the actor's per-command queue.
212/// 2. **Commit** — mutating fixup on the actor: insert into
213///    `self.torrents`, info-hash registry, queue position, alert, LSD
214///    announce. Runs on the actor in response to a `CommitAddTorrent`
215///    feedback command.
216///
217/// This bundle is the success-path payload of the prepare phase; the
218/// commit phase consumes it and produces the caller-visible `Id20`.
219/// `is_private` is precomputed from `meta.info.private` so the commit
220/// arm needs no async query to honour BEP 27 (LSD must skip private
221/// torrents).
222struct PreparedAddTorrent {
223    handle: TorrentHandle,
224    info_hash: Id20,
225    is_private: bool,
226    /// M170 post-add hooks (category label + paused-on-add). `None` for
227    /// the legacy `AddTorrent` path; `Some` only for the
228    /// `AddTorrentM170` path which carries `AddTorrentParams`.
229    m170_post: Option<M170PostAdd>,
230}
231
232/// M223 — M170 post-add side-effects deferred from the recv arm to the
233/// commit arm. Both are applied via `apply_post_add_m170` after the
234/// torrent is inserted into `self.torrents`.
235struct M170PostAdd {
236    category: Option<String>,
237    paused: bool,
238}
239
240/// M223 — snapshot of session state needed by the off-actor add-torrent
241/// prep phase. Built synchronously on the actor at the recv arm via
242/// `SessionActor::build_add_torrent_prep_bundle`; consumed by the
243/// spawned task that runs the heavy `disk_manager.register_torrent` +
244/// `TorrentHandle::from_torrent` work without blocking the actor's
245/// command queue.
246struct AddTorrentPrepBundle {
247    torrent_meta: irontide_core::TorrentMeta,
248    storage_override: Option<Arc<dyn TorrentStorage>>,
249    torrent_config: TorrentConfig,
250    disk_manager: crate::disk::DiskManagerHandle,
251    dht_v4_broadcast: irontide_dht::DhtBroadcast,
252    dht_v6_broadcast: irontide_dht::DhtBroadcast,
253    global_up: Option<SharedBucket>,
254    global_down: Option<SharedBucket>,
255    slot_tuner: crate::slot_tuner::SlotTuner,
256    alert_tx: broadcast::Sender<Alert>,
257    alert_mask: Arc<AtomicU32>,
258    utp_socket: Option<irontide_utp::UtpSocket>,
259    utp_socket_v6: Option<irontide_utp::UtpSocket>,
260    ban_manager: SharedBanManager,
261    ip_filter: SharedIpFilter,
262    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
263    sam_session: Option<Arc<crate::i2p::SamSession>>,
264    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
265    factory: Arc<crate::transport::NetworkFactory>,
266    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
267    counters: Arc<crate::stats::SessionCounters>,
268    m170_post: Option<M170PostAdd>,
269}
270
271impl TorrentEntry {
272    /// Returns `true` if this torrent has the private flag set (BEP 27).
273    ///
274    /// v0.173.1: now queries the `TorrentActor` for current metadata. Magnet
275    /// torrents previously silently returned `false` because the session
276    /// cache (`TorrentEntry.meta`) was permanently `None` for them — BEP 27
277    /// enforcement was bypassed and peer IPs leaked to DHT/LSD. This method
278    /// returning `false` pre-metadata is still correct per the plan's
279    /// Failure-Modes table: the info dict doesn't exist yet, so the flag
280    /// cannot be enforced. Once metadata resolves, subsequent calls see the
281    /// real `info.private` value.
282    async fn is_private(&self) -> bool {
283        match self.handle.get_meta().await {
284            Ok(Some(meta)) => meta.info.private == Some(1),
285            // Pre-metadata or actor shut down: treat as non-private. A magnet
286            // that hasn't resolved yet can't enforce privacy per BEP 27 — the
287            // flag lives in the info dict, which doesn't exist yet.
288            _ => false,
289        }
290    }
291}
292
293/// Commands sent from `SessionHandle` to `SessionActor`.
294enum SessionCommand {
295    AddTorrent {
296        meta: Box<irontide_core::TorrentMeta>,
297        storage: Option<Arc<dyn TorrentStorage>>,
298        download_dir: Option<PathBuf>,
299        reply: oneshot::Sender<crate::Result<Id20>>,
300    },
301    /// M223 — internal feedback variant. Carries the result of off-actor
302    /// `handle_add_torrent` work (TCP bind + disk register + actor spawn)
303    /// back to the actor's recv loop for the mutating fixup
304    /// (insert into `self.torrents` + queue position + alert + LSD).
305    /// Decouples the actor recv loop from per-add latency so the
306    /// parallel-7 POST tail does not stack linearly with already-added
307    /// torrents. Not part of the public `SessionHandle` API — only
308    /// `add_torrent_via_spawn` (the recv-arm helper) emits this variant.
309    CommitAddTorrent {
310        result: crate::Result<PreparedAddTorrent>,
311        reply: oneshot::Sender<crate::Result<Id20>>,
312    },
313    AddMagnet {
314        magnet: Magnet,
315        download_dir: Option<PathBuf>,
316        reply: oneshot::Sender<crate::Result<Id20>>,
317    },
318    RemoveTorrent {
319        info_hash: Id20,
320        reply: oneshot::Sender<crate::Result<()>>,
321    },
322    PauseTorrent {
323        info_hash: Id20,
324        reply: oneshot::Sender<crate::Result<()>>,
325    },
326    ResumeTorrent {
327        info_hash: Id20,
328        reply: oneshot::Sender<crate::Result<()>>,
329    },
330    ForceResumeTorrent {
331        info_hash: Id20,
332        reply: oneshot::Sender<crate::Result<()>>,
333    },
334    SetTorrentSeedRatio {
335        info_hash: Id20,
336        limit: Option<f64>,
337        reply: oneshot::Sender<crate::Result<()>>,
338    },
339    TorrentStats {
340        info_hash: Id20,
341        reply: oneshot::Sender<crate::Result<TorrentStats>>,
342    },
343    TorrentInfo {
344        info_hash: Id20,
345        reply: oneshot::Sender<crate::Result<TorrentInfo>>,
346    },
347    ListTorrents {
348        reply: oneshot::Sender<Vec<Id20>>,
349    },
350    SessionStats {
351        reply: oneshot::Sender<SessionStats>,
352    },
353    SaveTorrentResumeData {
354        info_hash: Id20,
355        reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
356    },
357    SaveSessionState {
358        reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
359    },
360    /// Load and restore torrents from per-torrent resume files on disk (M161).
361    LoadResumeState {
362        reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
363    },
364    QueuePosition {
365        info_hash: Id20,
366        reply: oneshot::Sender<crate::Result<i32>>,
367    },
368    SetQueuePosition {
369        info_hash: Id20,
370        pos: i32,
371        reply: oneshot::Sender<crate::Result<()>>,
372    },
373    QueuePositionUp {
374        info_hash: Id20,
375        reply: oneshot::Sender<crate::Result<()>>,
376    },
377    QueuePositionDown {
378        info_hash: Id20,
379        reply: oneshot::Sender<crate::Result<()>>,
380    },
381    QueuePositionTop {
382        info_hash: Id20,
383        reply: oneshot::Sender<crate::Result<()>>,
384    },
385    QueuePositionBottom {
386        info_hash: Id20,
387        reply: oneshot::Sender<crate::Result<()>>,
388    },
389    BanPeer {
390        ip: IpAddr,
391        reply: oneshot::Sender<()>,
392    },
393    UnbanPeer {
394        ip: IpAddr,
395        reply: oneshot::Sender<bool>,
396    },
397    BannedPeers {
398        reply: oneshot::Sender<Vec<IpAddr>>,
399    },
400    SetIpFilter {
401        filter: crate::ip_filter::IpFilter,
402        reply: oneshot::Sender<()>,
403    },
404    GetIpFilter {
405        reply: oneshot::Sender<crate::ip_filter::IpFilter>,
406    },
407    GetSettings {
408        reply: oneshot::Sender<Settings>,
409    },
410    ApplySettings {
411        settings: Box<Settings>,
412        reply: oneshot::Sender<crate::Result<()>>,
413    },
414    MoveTorrentStorage {
415        info_hash: Id20,
416        new_path: std::path::PathBuf,
417        reply: oneshot::Sender<crate::Result<()>>,
418    },
419    AddPeers {
420        info_hash: Id20,
421        peers: Vec<SocketAddr>,
422        source: crate::peer_state::PeerSource,
423        reply: oneshot::Sender<crate::Result<()>>,
424    },
425    OpenFile {
426        info_hash: Id20,
427        file_index: usize,
428        reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
429    },
430    ForceReannounce {
431        info_hash: Id20,
432        reply: oneshot::Sender<crate::Result<()>>,
433    },
434    TrackerList {
435        info_hash: Id20,
436        reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
437    },
438    /// M178 Lane B3 / TODO-2: `(pex_peer_count, lsd_peer_count)` for the
439    /// given torrent. Used by qBt v2 trackers endpoint + GUI Trackers tab
440    /// to populate the PeX/LSD pseudo-tracker rows with real counts.
441    GetPeerSourceCounts {
442        info_hash: Id20,
443        reply: oneshot::Sender<crate::Result<(usize, usize)>>,
444    },
445    /// Per-peer cumulative unchoke duration for a torrent. `None` reply
446    /// when the torrent does not exist (explicit contract — distinguishes
447    /// "torrent missing" from "torrent exists with empty map").
448    QueryUnchokeDurations {
449        info_hash: Id20,
450        reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
451    },
452    /// M178 Lane C: per-URL web-seed stats for the qBt v2 webseeds endpoint
453    /// and the GUI HTTP Sources tab.
454    GetWebSeedStats {
455        info_hash: Id20,
456        reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
457    },
458    Scrape {
459        info_hash: Id20,
460        reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
461    },
462    SetFilePriority {
463        info_hash: Id20,
464        index: usize,
465        priority: irontide_core::FilePriority,
466        reply: oneshot::Sender<crate::Result<()>>,
467    },
468    FilePriorities {
469        info_hash: Id20,
470        reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
471    },
472    SetDownloadLimit {
473        info_hash: Id20,
474        bytes_per_sec: u64,
475        reply: oneshot::Sender<crate::Result<()>>,
476    },
477    SetUploadLimit {
478        info_hash: Id20,
479        bytes_per_sec: u64,
480        reply: oneshot::Sender<crate::Result<()>>,
481    },
482    DownloadLimit {
483        info_hash: Id20,
484        reply: oneshot::Sender<crate::Result<u64>>,
485    },
486    UploadLimit {
487        info_hash: Id20,
488        reply: oneshot::Sender<crate::Result<u64>>,
489    },
490    SetSequentialDownload {
491        info_hash: Id20,
492        enabled: bool,
493        reply: oneshot::Sender<crate::Result<()>>,
494    },
495    IsSequentialDownload {
496        info_hash: Id20,
497        reply: oneshot::Sender<crate::Result<bool>>,
498    },
499    SetSuperSeeding {
500        info_hash: Id20,
501        enabled: bool,
502        reply: oneshot::Sender<crate::Result<()>>,
503    },
504    IsSuperSeeding {
505        info_hash: Id20,
506        reply: oneshot::Sender<crate::Result<bool>>,
507    },
508    /// Enable or disable user-requested seed-only mode for a torrent (M159).
509    SetSeedMode {
510        info_hash: Id20,
511        enabled: bool,
512        reply: oneshot::Sender<crate::Result<()>>,
513    },
514    AddTracker {
515        info_hash: Id20,
516        url: String,
517        reply: oneshot::Sender<crate::Result<()>>,
518    },
519    ReplaceTrackers {
520        info_hash: Id20,
521        urls: Vec<String>,
522        reply: oneshot::Sender<crate::Result<()>>,
523    },
524    /// Trigger a full piece verification (force recheck) for a torrent.
525    ForceRecheck {
526        info_hash: Id20,
527        reply: oneshot::Sender<crate::Result<()>>,
528    },
529    /// Rename a file within a torrent on disk.
530    RenameFile {
531        info_hash: Id20,
532        file_index: usize,
533        new_name: String,
534        reply: oneshot::Sender<crate::Result<()>>,
535    },
536    /// Set per-torrent maximum connections (0 = use global default).
537    SetMaxConnections {
538        info_hash: Id20,
539        limit: usize,
540        reply: oneshot::Sender<crate::Result<()>>,
541    },
542    /// Get per-torrent maximum connection limit.
543    MaxConnections {
544        info_hash: Id20,
545        reply: oneshot::Sender<crate::Result<usize>>,
546    },
547    /// Set per-torrent maximum upload slots (unchoke slots).
548    SetMaxUploads {
549        info_hash: Id20,
550        limit: usize,
551        reply: oneshot::Sender<crate::Result<()>>,
552    },
553    /// Get per-torrent maximum upload slots (unchoke slots).
554    MaxUploads {
555        info_hash: Id20,
556        reply: oneshot::Sender<crate::Result<usize>>,
557    },
558    /// Get per-peer details for all connected peers of a torrent.
559    GetPeerInfo {
560        info_hash: Id20,
561        reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
562    },
563    /// Get in-flight piece download status for a torrent.
564    GetDownloadQueue {
565        info_hash: Id20,
566        reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
567    },
568    /// Check whether a specific piece has been downloaded.
569    HavePiece {
570        info_hash: Id20,
571        index: u32,
572        reply: oneshot::Sender<crate::Result<bool>>,
573    },
574    /// Get per-piece availability counts from connected peers.
575    PieceAvailability {
576        info_hash: Id20,
577        reply: oneshot::Sender<crate::Result<Vec<u32>>>,
578    },
579    /// Get per-file bytes-downloaded progress.
580    FileProgress {
581        info_hash: Id20,
582        reply: oneshot::Sender<crate::Result<Vec<u64>>>,
583    },
584    /// Get the torrent's identity hashes (v1 and/or v2).
585    InfoHashesQuery {
586        info_hash: Id20,
587        reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
588    },
589    /// Get the full v1 metainfo for a torrent.
590    TorrentFile {
591        info_hash: Id20,
592        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
593    },
594    /// Get the full v2 metainfo for a torrent.
595    TorrentFileV2 {
596        info_hash: Id20,
597        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
598    },
599    /// Force an immediate DHT announce for a torrent.
600    ForceDhtAnnounce {
601        info_hash: Id20,
602        reply: oneshot::Sender<crate::Result<()>>,
603    },
604    /// Force an immediate LSD announce for a torrent (session-level only).
605    ForceLsdAnnounce {
606        info_hash: Id20,
607        reply: oneshot::Sender<crate::Result<()>>,
608    },
609    /// Read all data for a specific piece from disk.
610    ReadPiece {
611        info_hash: Id20,
612        index: u32,
613        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
614    },
615    /// Flush the disk write cache for a torrent.
616    FlushCache {
617        info_hash: Id20,
618        reply: oneshot::Sender<crate::Result<()>>,
619    },
620    /// Check if a torrent handle is still valid (torrent exists and channel open).
621    IsValid {
622        info_hash: Id20,
623        reply: oneshot::Sender<bool>,
624    },
625    /// Clear error state on a torrent.
626    ClearError {
627        info_hash: Id20,
628        reply: oneshot::Sender<crate::Result<()>>,
629    },
630    /// Get per-file open/mode status for a torrent.
631    FileStatus {
632        info_hash: Id20,
633        reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
634    },
635    /// Read the current torrent flags.
636    Flags {
637        info_hash: Id20,
638        reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
639    },
640    /// Set (enable) the specified torrent flags.
641    SetFlags {
642        info_hash: Id20,
643        flags: crate::types::TorrentFlags,
644        reply: oneshot::Sender<crate::Result<()>>,
645    },
646    /// Unset (disable) the specified torrent flags.
647    UnsetFlags {
648        info_hash: Id20,
649        flags: crate::types::TorrentFlags,
650        reply: oneshot::Sender<crate::Result<()>>,
651    },
652    /// Immediately initiate a peer connection for a torrent.
653    ConnectPeer {
654        info_hash: Id20,
655        addr: SocketAddr,
656        reply: oneshot::Sender<crate::Result<()>>,
657    },
658    DhtPutImmutable {
659        value: Vec<u8>,
660        reply: oneshot::Sender<crate::Result<Id20>>,
661    },
662    DhtGetImmutable {
663        target: Id20,
664        reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
665    },
666    DhtPutMutable {
667        keypair_bytes: [u8; 32],
668        value: Vec<u8>,
669        seq: i64,
670        salt: Vec<u8>,
671        reply: oneshot::Sender<crate::Result<Id20>>,
672    },
673    #[allow(clippy::type_complexity)]
674    DhtGetMutable {
675        public_key: [u8; 32],
676        salt: Vec<u8>,
677        reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
678    },
679    /// Save per-torrent resume files for all dirty torrents (M161).
680    SaveResumeState {
681        reply: oneshot::Sender<crate::Result<usize>>,
682    },
683    /// Trigger an immediate session stats snapshot and alert (M50).
684    PostSessionStats,
685    // ── M170: qBt v2 *arr-minimal surface ──
686    /// Unified add entry (M170) — see `AddTorrentParams`.
687    AddTorrentM170 {
688        params: Box<AddTorrentParams>,
689        reply: oneshot::Sender<crate::Result<Id20>>,
690    },
691    /// Create a new category with the given `save_path`.
692    CreateCategory {
693        name: String,
694        save_path: PathBuf,
695        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
696    },
697    /// Update the `save_path` on an existing category.
698    EditCategory {
699        name: String,
700        save_path: PathBuf,
701        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
702    },
703    /// Remove zero or more categories. Returns names actually removed.
704    RemoveCategories {
705        names: Vec<String>,
706        reply: oneshot::Sender<Vec<String>>,
707    },
708    /// Snapshot the current category list.
709    ListCategories {
710        reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
711    },
712    /// Create a batch of tags (M171). One reply slot per requested name
713    /// so the caller can tell which were newly created vs already-present.
714    CreateTags {
715        names: Vec<String>,
716        reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
717    },
718    /// Remove zero or more tags (M171). Returns names actually removed;
719    /// unknown names are tolerated (matches qBt idempotent `deleteTags`).
720    DeleteTags {
721        names: Vec<String>,
722        reply: oneshot::Sender<Vec<String>>,
723    },
724    /// Snapshot the current tag list (M171). Sorted.
725    ListTags {
726        reply: oneshot::Sender<Vec<String>>,
727    },
728    /// Add the given tags to each torrent in `info_hashes` (M171).
729    /// Unknown info hashes are silently skipped. The engine-layer
730    /// command is a wholesale replacement, so each torrent's tag set
731    /// is read, unioned with the requested additions, and replayed via
732    /// `TorrentHandle::set_tags`.
733    AddTagsToTorrents {
734        info_hashes: Vec<Id20>,
735        tags: Vec<String>,
736        reply: oneshot::Sender<crate::Result<()>>,
737    },
738    /// Remove the given tags from each torrent in `info_hashes` (M171).
739    /// Unknown info hashes are silently skipped.
740    RemoveTagsFromTorrents {
741        info_hashes: Vec<Id20>,
742        tags: Vec<String>,
743        reply: oneshot::Sender<crate::Result<()>>,
744    },
745    /// Remove a torrent and delete its on-disk files (qBt
746    /// `deleteFiles=true`).
747    RemoveTorrentWithFiles {
748        info_hash: Id20,
749        reply: oneshot::Sender<crate::Result<()>>,
750    },
751    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged)
752    /// for a specific torrent.
753    GetWebSeeds {
754        info_hash: Id20,
755        reply: oneshot::Sender<crate::Result<Vec<String>>>,
756    },
757    /// M171 Lane B: snapshot the per-piece state array as qBt codes
758    /// (`0`/`1`/`2`) for a specific torrent.
759    GetPieceStates {
760        info_hash: Id20,
761        reply: oneshot::Sender<crate::Result<Vec<u8>>>,
762    },
763    /// M171 Lane B: paginated piece hash list for a specific torrent.
764    GetPieceHashes {
765        info_hash: Id20,
766        offset: u32,
767        limit: u32,
768        reply: oneshot::Sender<crate::Result<Vec<String>>>,
769    },
770    /// M171 D4: sum of routing-table sizes across the IPv4 and IPv6 DHT
771    /// instances. Returns 0 when neither DHT is enabled.
772    DhtNodeCount {
773        reply: oneshot::Sender<usize>,
774    },
775    /// **TEST-ONLY (v0.173.2).** Inject info-dict bytes into a torrent's
776    /// actor synchronously, returning only after the actor has processed
777    /// it. Used by integration tests in `irontide-api` (A9) that exercise
778    /// post-metadata HTTP surface without spinning up real peers.
779    /// M187: collect per-torrent and per-peer debug state for diagnostics.
780    DebugState {
781        reply: oneshot::Sender<crate::types::DebugState>,
782    },
783    #[cfg(feature = "test-util")]
784    TestInjectMetadata {
785        info_hash: Id20,
786        info_bytes: Vec<u8>,
787        reply: oneshot::Sender<crate::Result<()>>,
788    },
789    Shutdown,
790}
791
792impl SessionCommand {
793    /// Static variant name for the `cmd` field of the M221.1a
794    /// `session_cmd` tracing event. Stable across renames is *not* a
795    /// goal — this is bench-instrumentation telemetry, so the variant
796    /// identifier is the right label.
797    fn name(&self) -> &'static str {
798        match self {
799            Self::AddTorrent { .. } => "AddTorrent",
800            Self::CommitAddTorrent { .. } => "CommitAddTorrent",
801            Self::AddMagnet { .. } => "AddMagnet",
802            Self::RemoveTorrent { .. } => "RemoveTorrent",
803            Self::PauseTorrent { .. } => "PauseTorrent",
804            Self::ResumeTorrent { .. } => "ResumeTorrent",
805            Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
806            Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
807            Self::TorrentStats { .. } => "TorrentStats",
808            Self::TorrentInfo { .. } => "TorrentInfo",
809            Self::ListTorrents { .. } => "ListTorrents",
810            Self::SessionStats { .. } => "SessionStats",
811            Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
812            Self::SaveSessionState { .. } => "SaveSessionState",
813            Self::LoadResumeState { .. } => "LoadResumeState",
814            Self::QueuePosition { .. } => "QueuePosition",
815            Self::SetQueuePosition { .. } => "SetQueuePosition",
816            Self::QueuePositionUp { .. } => "QueuePositionUp",
817            Self::QueuePositionDown { .. } => "QueuePositionDown",
818            Self::QueuePositionTop { .. } => "QueuePositionTop",
819            Self::QueuePositionBottom { .. } => "QueuePositionBottom",
820            Self::BanPeer { .. } => "BanPeer",
821            Self::UnbanPeer { .. } => "UnbanPeer",
822            Self::BannedPeers { .. } => "BannedPeers",
823            Self::SetIpFilter { .. } => "SetIpFilter",
824            Self::GetIpFilter { .. } => "GetIpFilter",
825            Self::GetSettings { .. } => "GetSettings",
826            Self::ApplySettings { .. } => "ApplySettings",
827            Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
828            Self::AddPeers { .. } => "AddPeers",
829            Self::OpenFile { .. } => "OpenFile",
830            Self::ForceReannounce { .. } => "ForceReannounce",
831            Self::TrackerList { .. } => "TrackerList",
832            Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
833            Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
834            Self::GetWebSeedStats { .. } => "GetWebSeedStats",
835            Self::Scrape { .. } => "Scrape",
836            Self::SetFilePriority { .. } => "SetFilePriority",
837            Self::FilePriorities { .. } => "FilePriorities",
838            Self::SetDownloadLimit { .. } => "SetDownloadLimit",
839            Self::SetUploadLimit { .. } => "SetUploadLimit",
840            Self::DownloadLimit { .. } => "DownloadLimit",
841            Self::UploadLimit { .. } => "UploadLimit",
842            Self::SetSequentialDownload { .. } => "SetSequentialDownload",
843            Self::IsSequentialDownload { .. } => "IsSequentialDownload",
844            Self::SetSuperSeeding { .. } => "SetSuperSeeding",
845            Self::IsSuperSeeding { .. } => "IsSuperSeeding",
846            Self::SetSeedMode { .. } => "SetSeedMode",
847            Self::AddTracker { .. } => "AddTracker",
848            Self::ReplaceTrackers { .. } => "ReplaceTrackers",
849            Self::ForceRecheck { .. } => "ForceRecheck",
850            Self::RenameFile { .. } => "RenameFile",
851            Self::SetMaxConnections { .. } => "SetMaxConnections",
852            Self::MaxConnections { .. } => "MaxConnections",
853            Self::SetMaxUploads { .. } => "SetMaxUploads",
854            Self::MaxUploads { .. } => "MaxUploads",
855            Self::GetPeerInfo { .. } => "GetPeerInfo",
856            Self::GetDownloadQueue { .. } => "GetDownloadQueue",
857            Self::HavePiece { .. } => "HavePiece",
858            Self::PieceAvailability { .. } => "PieceAvailability",
859            Self::FileProgress { .. } => "FileProgress",
860            Self::InfoHashesQuery { .. } => "InfoHashesQuery",
861            Self::TorrentFile { .. } => "TorrentFile",
862            Self::TorrentFileV2 { .. } => "TorrentFileV2",
863            Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
864            Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
865            Self::ReadPiece { .. } => "ReadPiece",
866            Self::FlushCache { .. } => "FlushCache",
867            Self::IsValid { .. } => "IsValid",
868            Self::ClearError { .. } => "ClearError",
869            Self::FileStatus { .. } => "FileStatus",
870            Self::Flags { .. } => "Flags",
871            Self::SetFlags { .. } => "SetFlags",
872            Self::UnsetFlags { .. } => "UnsetFlags",
873            Self::ConnectPeer { .. } => "ConnectPeer",
874            Self::DhtPutImmutable { .. } => "DhtPutImmutable",
875            Self::DhtGetImmutable { .. } => "DhtGetImmutable",
876            Self::DhtPutMutable { .. } => "DhtPutMutable",
877            Self::DhtGetMutable { .. } => "DhtGetMutable",
878            Self::SaveResumeState { .. } => "SaveResumeState",
879            Self::PostSessionStats => "PostSessionStats",
880            Self::AddTorrentM170 { .. } => "AddTorrentM170",
881            Self::CreateCategory { .. } => "CreateCategory",
882            Self::EditCategory { .. } => "EditCategory",
883            Self::RemoveCategories { .. } => "RemoveCategories",
884            Self::ListCategories { .. } => "ListCategories",
885            Self::CreateTags { .. } => "CreateTags",
886            Self::DeleteTags { .. } => "DeleteTags",
887            Self::ListTags { .. } => "ListTags",
888            Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
889            Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
890            Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
891            Self::GetWebSeeds { .. } => "GetWebSeeds",
892            Self::GetPieceStates { .. } => "GetPieceStates",
893            Self::GetPieceHashes { .. } => "GetPieceHashes",
894            Self::DhtNodeCount { .. } => "DhtNodeCount",
895            Self::DebugState { .. } => "DebugState",
896            #[cfg(feature = "test-util")]
897            Self::TestInjectMetadata { .. } => "TestInjectMetadata",
898            Self::Shutdown => "Shutdown",
899        }
900    }
901}
902
903/// Channel sender that timestamps each outgoing `SessionCommand` with
904/// the instant it was enqueued, so the actor's receive arm can split
905/// `queue_wait_ms` (sender → receiver) from `handler_ms` (dispatch).
906/// M221.1a — feeds the parallel-7 bench harness.
907#[derive(Clone)]
908struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
909
910impl SessionCmdSender {
911    async fn send(
912        &self,
913        cmd: SessionCommand,
914    ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
915        let sent_at = tokio::time::Instant::now();
916        self.0
917            .send((sent_at, cmd))
918            .await
919            .map_err(|e| mpsc::error::SendError(e.0.1))
920    }
921}
922
923/// Classification of settings-patch fields into "took effect immediately"
924/// versus "requires session restart to apply".
925///
926/// Returned by [`SessionHandle::apply_settings_classified`]. The
927/// `restart_required` list is surfaced as the `X-IronTide-Restart-Pending`
928/// response header by the qBt v2 `setPreferences` handler so clients can
929/// render a "restart to apply" UX affordance (M171 D3.5).
930#[derive(Debug, Clone, Default)]
931pub struct AppliedSettings {
932    /// Settings fields that changed and took effect immediately.
933    pub immediate: Vec<&'static str>,
934    /// Settings fields that changed but require a session restart to
935    /// activate (sub-actor reconfig, listen-socket rebind, DHT/LSD/PEX
936    /// startup, encryption-handshake policy, anonymous-mode peer ID, etc.).
937    pub restart_required: Vec<&'static str>,
938}
939
940// ─────────────────────────────────────────────────────────────────────────
941// Settings reconfiguration classification — CODEGEN from the SSOT registry
942// (M247a). The reconfiguration class (`immediate` / `restart` / `stored`) and
943// qBt wire-name of every setting are declared ONCE in `irontide_settings`'
944// exported `for_each_setting!` / `for_each_qbt_compat_setting!` /
945// `for_each_proxy_setting!` registries (crates/irontide-settings/src/schema.rs).
946// The two `classify_*` projections below are GENERATED from those registries
947// rather than hand-maintained, so a field can never drift out of sync between
948// its declaration and its reconfiguration class.
949//
950// Mechanism (tracer-proven, M247a Task 1):
951//   * `push_if!` emits a `v.push(wire)` guard IFF the entry's `class` matches
952//     the target class. The class is forwarded as a `:tt` (NOT captured as an
953//     `:ident`) — a captured `:ident` is opaque downstream and would not
954//     re-match the literal-keyword arms (Trap 3).
955//   * one tiny emitter per (target-class, accessor-prefix) generates an
956//     appender fn; the registry's `$(…)*` repetition expands one `push_if!`
957//     per field. `stored` fields (and class-mismatches) expand to nothing, so
958//     their accessor is never even referenced.
959//   * the `classify_*` wrappers call the appenders in sequence. The output
960//     ORDER differs from the pre-M247a hand-written interleaving, but every
961//     consumer and test treats the result as a SET (`.contains()` / `HashSet`
962//     equality — see the golden tests in this module), so order is immaterial.
963// ─────────────────────────────────────────────────────────────────────────
964
965/// Emits `if o.<acc> != n.<acc> { v.push(wire); }` IFF the entry's `class`
966/// (arg 2, forwarded as `:tt`) equals the target class (arg 1). Every other
967/// class — including `stored` — matches the final no-op arm and expands to
968/// nothing, so a non-projected field's accessor is never referenced.
969macro_rules! push_if {
970    (immediate, immediate, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
971        if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
972    };
973    (restart, restart, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
974        if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
975    };
976    ($t:tt, $c:tt, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {};
977}
978
979// One appender per (target-class, accessor-prefix). Each emitter generates a
980// free fn over its whole registry; `push_if!` keeps only the matching class.
981macro_rules! emit_immediate_top {
982    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
983        fn append_immediate_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
984            $( push_if!(immediate, $class, o, n, ($name), $wire, v); )*
985        }
986    };
987}
988irontide_settings::for_each_setting!(emit_immediate_top);
989
990macro_rules! emit_immediate_qbt {
991    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
992        fn append_immediate_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
993            $( push_if!(immediate, $class, o, n, (qbt_compat . $name), $wire, v); )*
994        }
995    };
996}
997irontide_settings::for_each_qbt_compat_setting!(emit_immediate_qbt);
998
999macro_rules! emit_restart_top {
1000    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1001        fn append_restart_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1002            $( push_if!(restart, $class, o, n, ($name), $wire, v); )*
1003        }
1004    };
1005}
1006irontide_settings::for_each_setting!(emit_restart_top);
1007
1008macro_rules! emit_restart_qbt {
1009    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1010        fn append_restart_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1011            $( push_if!(restart, $class, o, n, (qbt_compat . $name), $wire, v); )*
1012        }
1013    };
1014}
1015irontide_settings::for_each_qbt_compat_setting!(emit_restart_qbt);
1016
1017macro_rules! emit_restart_proxy {
1018    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1019        fn append_restart_proxy(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1020            $( push_if!(restart, $class, o, n, (proxy . $name), $wire, v); )*
1021        }
1022    };
1023}
1024irontide_settings::for_each_proxy_setting!(emit_restart_proxy);
1025
1026/// Classify fields whose runtime application is immediate — rate limiters
1027/// (M166), alert mask, peer cap, and enum/flag state that peer/torrent
1028/// actors re-read on every tick.
1029///
1030/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, and `lsd`
1031/// move from `restart_required` to `immediate`. The transactional
1032/// apply pipeline (B1) now performs the live reconfig:
1033/// - `listen_port`: TCP listener rebind + uTP `shutdown_and_wait` + new
1034///   bind + NAT `refresh_for_port` (B2/B4/B8 primitives).
1035/// - dht: `shutdown_and_wait` (B7 persists routing table) + new
1036///   `DhtHandle` + `DhtBroadcast::replace` fans out to torrents (B5+B6).
1037/// - lsd: `shutdown_and_wait` (B9 multicast fd guard) + new actor.
1038///
1039/// **Body generated (M247a)** from `for_each_setting!` +
1040/// `for_each_qbt_compat_setting!` via `append_immediate_top` /
1041/// `append_immediate_qbt`.
1042///
1043/// `[REGRESSION CRITICAL]`: the wire-format pinned test in irontide-client's
1044/// `crates/irontide-api/tests/qbt_v2_set_preferences.rs` (cross-repo; runs
1045/// against the published crate) asserts the EXACT field-name set here.
1046/// Downstream *arr clients parse the `X-IronTide-Restart-Pending` header — a
1047/// silent rename = downstream regression. Change a `wire:` in the registry
1048/// only with that contract in mind; never reuse a field name.
1049fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
1050    let mut v = Vec::new();
1051    append_immediate_top(old, new, &mut v);
1052    append_immediate_qbt(old, new, &mut v);
1053    v
1054}
1055
1056/// Classify fields whose runtime application STILL requires a session
1057/// restart, post-M173 Lane B graduation:
1058/// - PEX (peer announcement on next handshake — propagation cost)
1059/// - encryption handshake policy (MSE bytes-on-wire)
1060/// - anonymous-mode peer ID
1061/// - download-dir root (in-flight torrents retain their original
1062///   `save_path`; only `next-add` changes — this is intentional, not
1063///   a bug, see HA spec non-goals)
1064///
1065/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, `lsd` were
1066/// removed from this list; they now appear in [`classify_immediate`].
1067/// The transactional apply pipeline (B1) performs their live reconfig.
1068///
1069/// **Body generated (M247a)** from `for_each_setting!` +
1070/// `for_each_qbt_compat_setting!` + `for_each_proxy_setting!` via
1071/// `append_restart_top` / `append_restart_qbt` / `append_restart_proxy`.
1072fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1073    let mut v = Vec::new();
1074    append_restart_top(old, new, &mut v);
1075    append_restart_qbt(old, new, &mut v);
1076    append_restart_proxy(old, new, &mut v);
1077    v
1078}
1079
1080/// Cloneable handle for interacting with a running session.
1081#[derive(Clone)]
1082pub struct SessionHandle {
1083    cmd_tx: SessionCmdSender,
1084    alert_tx: broadcast::Sender<Alert>,
1085    alert_mask: Arc<AtomicU32>,
1086    counters: Arc<crate::stats::SessionCounters>,
1087    /// Network transport factory (M51). Used by future simulation tasks.
1088    #[allow(dead_code)]
1089    factory: Arc<crate::transport::NetworkFactory>,
1090    /// M173 Lane B (B11): in-flight guard for concurrent
1091    /// `apply_settings` / `apply_settings_classified` calls. The
1092    /// guard is acquired BEFORE the command is queued; if a
1093    /// previous reconfig is still in flight, the second caller hits
1094    /// `Error::ConcurrentReconfig` (HTTP 409 Conflict on the qBt
1095    /// v2 `setPreferences` endpoint) instead of racing the first
1096    /// caller's read-modify-write.
1097    reconfig_in_flight: crate::apply::ReconfigInFlight,
1098    /// M245 A1 — lock-free published read-model. Read-only callers
1099    /// ([`list_torrent_summaries`](Self::list_torrent_summaries)) load an
1100    /// `Arc` snapshot here instead of round-tripping the command mailbox.
1101    /// The [`SessionActor`] is the SOLE writer: it patches membership
1102    /// eagerly on add/remove and refreshes sampled stats once per stats
1103    /// tick. Same `ArcSwap` the actor holds (cloned at construction).
1104    snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
1105}
1106
1107impl SessionHandle {
1108    /// Start a new session with the given settings and no plugins.
1109    ///
1110    /// # Errors
1111    ///
1112    /// Returns an error if the connection or binding fails.
1113    pub async fn start(settings: Settings) -> crate::Result<Self> {
1114        Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1115    }
1116
1117    /// Start a new session with a custom disk I/O backend and no plugins.
1118    ///
1119    /// # Errors
1120    ///
1121    /// Returns an error if the connection or binding fails.
1122    pub async fn start_with_backend(
1123        settings: Settings,
1124        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1125    ) -> crate::Result<Self> {
1126        Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1127    }
1128
1129    /// Start a new session with the given settings and extension plugins.
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an error if the connection or binding fails.
1134    pub async fn start_with_plugins(
1135        settings: Settings,
1136        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1137    ) -> crate::Result<Self> {
1138        let disk_config = crate::disk::DiskConfig::from(&settings);
1139        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1140        Self::start_with_plugins_and_backend(settings, plugins, backend).await
1141    }
1142
1143    /// Start a new session with the given settings, extension plugins, and
1144    /// a custom disk I/O backend.
1145    ///
1146    /// # Errors
1147    ///
1148    /// Returns an error if the connection or binding fails.
1149    pub async fn start_with_plugins_and_backend(
1150        settings: Settings,
1151        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1152        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1153    ) -> crate::Result<Self> {
1154        Self::start_full(
1155            settings,
1156            plugins,
1157            backend,
1158            Arc::new(crate::transport::NetworkFactory::tokio()),
1159        )
1160        .await
1161    }
1162
1163    /// Start a new session with the given settings and a custom transport factory.
1164    ///
1165    /// Uses default plugins (none) and default disk backend.
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an error if the connection or binding fails.
1170    pub async fn start_with_transport(
1171        settings: Settings,
1172        factory: Arc<crate::transport::NetworkFactory>,
1173    ) -> crate::Result<Self> {
1174        let disk_config = crate::disk::DiskConfig::from(&settings);
1175        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1176        Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1177    }
1178
1179    /// Start a new session with all customizable parameters.
1180    ///
1181    /// This is the most general constructor — all other `start_*` variants
1182    /// delegate to this method. The `factory` parameter controls how TCP
1183    /// listeners and connections are created: use [`crate::transport::NetworkFactory::tokio()`]
1184    /// for real networking or a custom factory for simulation.
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns an error if the connection or binding fails.
1189    pub async fn start_full(
1190        settings: Settings,
1191        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1192        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1193        factory: Arc<crate::transport::NetworkFactory>,
1194    ) -> crate::Result<Self> {
1195        let mut settings = settings;
1196
1197        // Force proxy mode: all connections must go through proxy.
1198        if settings.force_proxy {
1199            if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1200                return Err(crate::Error::Config(
1201                    "force_proxy requires a proxy to be configured".into(),
1202                ));
1203            }
1204            settings.enable_upnp = false;
1205            settings.enable_natpmp = false;
1206            settings.enable_dht = false;
1207            settings.enable_lsd = false;
1208        }
1209
1210        // Anonymous mode: suppress identity and disable discovery.
1211        if settings.anonymous_mode {
1212            settings.enable_dht = false;
1213            settings.enable_lsd = false;
1214            settings.enable_upnp = false;
1215            settings.enable_natpmp = false;
1216        }
1217
1218        // M172a A3/C2: legacy-plaintext → argon2id migration on the in-memory
1219        // Settings. The config file rewrite is a separate concern handled by
1220        // the CLI layer (see irontide_config::migrate_qbt_credentials_in_file)
1221        // — we only mutate the runtime copy here so login always uses PHC
1222        // hashes regardless of on-disk state.
1223        //
1224        // Failure semantics (C2): on hash error we keep the plaintext in
1225        // memory so verify still works, and emit a WARN. The daemon continues
1226        // — a startup crash would brick an operator on a transient OS error.
1227        match irontide_settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1228            Ok(irontide_settings::QbtCredentialMigration::Upgraded) => {
1229                warn!(
1230                    "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1231                     persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1232                     next config-touching CLI command to remove the plaintext from disk"
1233                );
1234            }
1235            Ok(irontide_settings::QbtCredentialMigration::NoOp) => {}
1236            Err(e) => {
1237                warn!(
1238                    error = %e,
1239                    "qbt_compat: in-memory password migration failed — continuing with \
1240                     legacy plaintext; retry on next daemon start"
1241                );
1242            }
1243        }
1244
1245        let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1246        let cmd_tx = SessionCmdSender(raw_cmd_tx);
1247
1248        // Alert broadcast channel
1249        let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1250        let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1251
1252        // M226 Step 5: spawn the engine-side OS notification dispatcher.
1253        // The dispatcher subscribes to the alert broadcast BEFORE any
1254        // TorrentAdded can fire (H5 — eliminates the missed-cache race
1255        // on first add), reads `notify_on_complete` / `notify_on_error`
1256        // live from a watch channel mirrored by `handle_apply_settings`,
1257        // and exits when either the `notification_shutdown_tx` field on
1258        // `SessionActor` drops OR the alert broadcast closes. Production
1259        // uses `LibNotifySink` (wraps `notify-rust` via spawn_blocking);
1260        // first D-Bus failure logs a single WARN then degrades silently.
1261        let (notification_settings_tx, notification_settings_rx) =
1262            tokio::sync::watch::channel(settings.clone());
1263        let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1264        let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1265            crate::notification::DispatcherOptions {
1266                sink: Box::new(crate::notification::LibNotifySink::new()),
1267                settings_rx: notification_settings_rx,
1268                alerts_rx: alert_tx.subscribe(),
1269                shutdown_rx: notification_shutdown_rx,
1270            },
1271        );
1272
1273        // M226 Step 6: prepare the watched-folder dispatcher's channels.
1274        // The dispatcher itself is spawned at the very end of `start_full`
1275        // (it needs a fully-built `SessionHandle` to clone), but the
1276        // shutdown signal + change-notify must exist before SessionActor
1277        // construction so they can be stored on the actor.
1278        let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1279        let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) = oneshot::channel::<()>();
1280        // Fresh settings subscription dedicated to the watcher (a watch
1281        // Receiver is single-consumer; the notification dispatcher
1282        // already consumed the original).
1283        let watched_folder_settings_rx = notification_settings_tx.subscribe();
1284
1285        let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1286            match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1287                Ok((handle, rx)) => (Some(handle), Some(rx)),
1288                Err(e) => {
1289                    warn!("LSD unavailable (port 6771): {e}");
1290                    (None, None)
1291                }
1292            }
1293        } else {
1294            (None, None)
1295        };
1296
1297        let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1298            crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1299        ));
1300        let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1301            crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1302        ));
1303
1304        // M225 G4: build the shared admit-gate state BEFORE binding the uTP
1305        // socket so the SocketActor's inbound-SYN path can read from the
1306        // same `Arc<AtomicI32>` / `Arc<AtomicUsize>` / `SharedIpFilter` the
1307        // TCP listener and `handle_apply_settings` use. These three
1308        // allocations are otherwise constructed further down (lines below);
1309        // hoisting them avoids a separate setter on `UtpSocket` and the
1310        // race window it would open.
1311        let ip_filter: SharedIpFilter =
1312            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1313        let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1314            settings.max_connections_global,
1315        ));
1316        let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1317
1318        let utp_admit = {
1319            let ip_filter_for_utp = Arc::clone(&ip_filter);
1320            irontide_utp::AdmitGate::new(
1321                Arc::clone(&max_connections_global),
1322                Arc::clone(&live_connections),
1323                Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1324            )
1325        };
1326
1327        // uTP socket (shared across all torrents).
1328        //
1329        // Stage U: when the factory exposes a `bind_udp` closure (sim
1330        // path), bind the production `UtpSocket` on top of the factory's
1331        // `UdpTransport` so BEP 29 runs unmodified through the in-memory
1332        // packet bus. The tokio factory leaves `bind_udp` unset and the
1333        // direct `UtpSocket::bind` path runs (it owns the FD-level DSCP /
1334        // TCLASS setsockopt, which the sim has no equivalent for).
1335        let (utp_socket, utp_listener) = if settings.enable_utp {
1336            let utp_config = settings.to_utp_config(settings.listen_port);
1337            let bind_addr = utp_config.bind_addr;
1338            let result = if factory.has_bind_udp() {
1339                match factory.bind_udp(bind_addr).await {
1340                    Ok(transport) => irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1341                        transport,
1342                        utp_config,
1343                        utp_admit.clone(),
1344                    ),
1345                    Err(e) => Err(irontide_utp::Error::Io(e)),
1346                }
1347            } else {
1348                irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1349            };
1350            match result {
1351                Ok((socket, listener)) => (Some(socket), Some(listener)),
1352                Err(e) => {
1353                    warn!("uTP bind failed: {e}");
1354                    (None, None)
1355                }
1356            }
1357        } else {
1358            (None, None)
1359        };
1360
1361        // IPv6 uTP socket (dual-stack). Sim path skips IPv6 — the sim
1362        // network only models v4 today.
1363        let (utp_socket_v6, utp_listener_v6) =
1364            if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1365                match irontide_utp::UtpSocket::bind_with_admit_gate(
1366                    settings.to_utp_config_v6(settings.listen_port),
1367                    utp_admit.clone(),
1368                )
1369                .await
1370                {
1371                    Ok((socket, listener)) => (Some(socket), Some(listener)),
1372                    Err(e) => {
1373                        debug!("uTP IPv6 bind failed (non-fatal): {e}");
1374                        (None, None)
1375                    }
1376                }
1377            } else {
1378                (None, None)
1379            };
1380
1381        // NAT port mapping (PCP / NAT-PMP / UPnP)
1382        let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1383            let nat_config = settings.to_nat_config();
1384            let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1385            let udp_port = if settings.enable_utp {
1386                Some(settings.listen_port)
1387            } else {
1388                None
1389            };
1390            handle.map_ports(settings.listen_port, udp_port).await;
1391            (Some(handle), Some(events_rx))
1392        } else {
1393            (None, None)
1394        };
1395
1396        // I2P SAM session
1397        let sam_session = if settings.enable_i2p {
1398            let tunnel_config = settings.to_sam_tunnel_config();
1399            match crate::i2p::SamSession::create(
1400                &settings.i2p_hostname,
1401                settings.i2p_port,
1402                "torrent",
1403                tunnel_config,
1404            )
1405            .await
1406            {
1407                Ok(session) => {
1408                    let b32 = session.destination().to_b32_address();
1409                    info!("I2P SAM session created: {}", b32);
1410                    post_alert(
1411                        &alert_tx,
1412                        &alert_mask,
1413                        AlertKind::I2pSessionCreated { b32_address: b32 },
1414                    );
1415                    Some(Arc::new(session))
1416                }
1417                Err(e) => {
1418                    warn!("I2P SAM session failed: {e}");
1419                    post_alert(
1420                        &alert_tx,
1421                        &alert_mask,
1422                        AlertKind::I2pError {
1423                            message: format!("SAM session creation failed: {e}"),
1424                        },
1425                    );
1426                    None
1427                }
1428            }
1429        } else {
1430            None
1431        };
1432
1433        // SSL manager (M42): create if ssl_listen_port != 0 or cert paths are provided
1434        let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1435            match crate::ssl_manager::SslManager::new(&settings) {
1436                Ok(mgr) => {
1437                    info!("SSL manager initialized");
1438                    Some(Arc::new(mgr))
1439                }
1440                Err(e) => {
1441                    warn!(error = %e, "SSL manager initialization failed");
1442                    None
1443                }
1444            }
1445        } else {
1446            None
1447        };
1448
1449        // TCP listener: bind on the main listen port for incoming peer connections.
1450        let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1451            .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1452            .await
1453        {
1454            Ok(l) => {
1455                info!(port = settings.listen_port, "TCP listener started");
1456                Some(l)
1457            }
1458            Err(e) => {
1459                warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1460                None
1461            }
1462        };
1463
1464        // SSL listener (M42): bind if ssl_listen_port != 0
1465        let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1466            .ssl_listen_port
1467            != 0
1468        {
1469            match factory
1470                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1471                .await
1472            {
1473                Ok(l) => {
1474                    info!(port = settings.ssl_listen_port, "SSL listener started");
1475                    Some(l)
1476                }
1477                Err(e) => {
1478                    warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1479                    None
1480                }
1481            }
1482        } else {
1483            None
1484        };
1485
1486        // Start DHT instances
1487        let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1488            match DhtHandle::start(settings.to_dht_config()).await {
1489                Ok((handle, ip_rx)) => {
1490                    info!("DHT v4 started");
1491                    (Some(handle), Some(ip_rx))
1492                }
1493                Err(e) => {
1494                    warn!("DHT v4 start failed: {e}");
1495                    (None, None)
1496                }
1497            }
1498        } else {
1499            (None, None)
1500        };
1501
1502        let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1503            match DhtHandle::start(settings.to_dht_config_v6()).await {
1504                Ok((handle, ip_rx)) => {
1505                    info!("DHT v6 started");
1506                    (Some(handle), Some(ip_rx))
1507                }
1508                Err(e) => {
1509                    debug!("DHT v6 start failed (non-fatal): {e}");
1510                    (None, None)
1511                }
1512            }
1513        } else {
1514            (None, None)
1515        };
1516
1517        // M173 Lane B (B6): seed the broadcast surfaces with the
1518        // initial DHT handles so consumers see exactly the same
1519        // value via the broadcast as the legacy `dht_v4`/`dht_v6`
1520        // fields. B11's apply_settings DHT-restart phase later
1521        // updates the broadcasts via `replace`.
1522        let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1523        let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1524
1525        let ban_config = crate::ban::BanConfig::from(&settings);
1526        let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1527            crate::ban::BanManager::new(ban_config),
1528        ));
1529
1530        // M225 G4: `ip_filter` was hoisted above the uTP bind so the
1531        // SocketActor's admit-gate closure captures the same Arc.
1532
1533        let disk_config = crate::disk::DiskConfig::from(&settings);
1534        let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1535        let (disk_manager, disk_actor_handle) =
1536            crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1537
1538        let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1539            settings.enable_diagnostic_counters,
1540        ));
1541
1542        // M96: Create shared hash pool for parallel piece verification
1543        let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1544            settings.hashing_threads,
1545            64,
1546        ));
1547
1548        // M114: Spawn isolated listener task for TCP/uTP accepts.
1549        let info_hash_registry = Arc::new(DashMap::new());
1550        let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1551        // M224 D3 / M225 G4: `max_connections_global` and `live_connections`
1552        // were hoisted above the uTP bind so the SocketActor's admit gate
1553        // shares them with this listener. handle_apply_settings updates the
1554        // i32 atomic in-place; both transports read it on the next admit.
1555        let listener_task = crate::listener::ListenerTask::new(
1556            tcp_listener,
1557            utp_listener,
1558            utp_listener_v6,
1559            Arc::clone(&info_hash_registry),
1560            validated_tx,
1561            Arc::clone(&max_connections_global),
1562            Arc::clone(&live_connections),
1563        );
1564        // M173 Lane B (B2): spawn via ListenerHandle::spawn so the
1565        // shutdown channel is plumbed in. We hold the full
1566        // `ListenerHandle` (not just the JoinHandle) so the future
1567        // listen-port rebind path (B4) can call
1568        // `shutdown_with_timeout` for a clean port swap. Session
1569        // teardown still works via Drop on the `ListenerHandle` →
1570        // shutdown sender Drop → receiver fires `RecvError` → loop
1571        // exits.
1572        let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1573
1574        let external_ip = settings.external_ip;
1575
1576        // M170: load the category registry once on startup. Errors are
1577        // soft-recovered inside `CategoryRegistry::load`, so this call
1578        // cannot fail in practice.
1579        let category_registry_path = crate::category_manager::resolve_category_registry_path(
1580            settings.category_registry_path.as_deref(),
1581        );
1582        let category_registry = Arc::new(parking_lot::RwLock::new(
1583            crate::category_manager::CategoryRegistry::load(category_registry_path),
1584        ));
1585        // M171: same pattern as the category registry — soft-recover inside
1586        // `TagRegistry::load`, so this never fails in practice.
1587        let tag_registry_path =
1588            crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1589        let tag_registry = Arc::new(parking_lot::RwLock::new(
1590            crate::tag_manager::TagRegistry::load(tag_registry_path),
1591        ));
1592        let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1593        let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1594        // M245 A1 — the lock-free published read-model. Built empty (the
1595        // `torrents` map starts empty too); the actor patches it on every
1596        // add/remove and refreshes stats each tick. `Arc::clone`d into the
1597        // actor (sole writer) and moved into the handle (reader) below.
1598        let snapshot = Arc::new(arc_swap::ArcSwap::from_pointee(SessionSnapshot::default()));
1599
1600        let actor = SessionActor {
1601            settings,
1602            // M223 — clone so the actor can self-emit CommitAddTorrent
1603            // from off-actor prep tasks. The handle still owns the
1604            // original sender; both feed the same recv queue.
1605            commit_tx: cmd_tx.clone(),
1606            torrents: HashMap::new(),
1607            snapshot: Arc::clone(&snapshot),
1608            dht_v4,
1609            dht_v6,
1610            dht_v4_broadcast,
1611            dht_v6_broadcast,
1612            lsd,
1613            lsd_peers_rx,
1614            cmd_rx,
1615            alert_tx: alert_tx.clone(),
1616            alert_mask: Arc::clone(&alert_mask),
1617            global_upload_bucket,
1618            global_download_bucket,
1619            utp_socket,
1620            utp_socket_v6,
1621            nat,
1622            nat_events_rx,
1623            ban_manager,
1624            ip_filter,
1625            disk_manager,
1626            disk_actor_handle,
1627            external_ip,
1628            dht_v4_ip_rx,
1629            dht_v6_ip_rx,
1630            plugins,
1631            sam_session,
1632            ssl_manager,
1633            ssl_listener,
1634            validated_conn_rx,
1635            info_hash_registry,
1636            _listener_task: listener_handle,
1637            max_connections_global,
1638            live_connections,
1639            counters: Arc::clone(&counters),
1640            factory: Arc::clone(&factory),
1641            hash_pool,
1642            category_registry,
1643            tag_registry,
1644            deletion_grace,
1645            // Clone so the SessionHandle can hold the same guard.
1646            reconfig_in_flight: reconfig_in_flight.clone(),
1647            self_alert_rx: alert_tx.subscribe(),
1648            resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1649            resume_save_lock: Arc::new(tokio::sync::Mutex::new(())),
1650            notification_settings_tx,
1651            notification_shutdown_tx,
1652            watched_folder_changed: Arc::clone(&watched_folder_changed),
1653            watched_folder_shutdown_tx,
1654        };
1655
1656        let join_handle = tokio::spawn(actor.run());
1657        tokio::spawn(async move {
1658            match join_handle.await {
1659                Ok(()) => {
1660                    tracing::warn!("session actor exited cleanly");
1661                }
1662                Err(e) if e.is_panic() => {
1663                    let panic_payload = e.into_panic();
1664                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1665                        (*s).to_string()
1666                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1667                        s.clone()
1668                    } else {
1669                        "unknown panic payload".to_string()
1670                    };
1671                    tracing::error!("session actor PANICKED: {msg}");
1672                }
1673                Err(e) => {
1674                    tracing::error!("session actor task error: {e}");
1675                }
1676            }
1677        });
1678        let handle = Self {
1679            cmd_tx,
1680            alert_tx,
1681            alert_mask,
1682            counters,
1683            factory,
1684            // M173 Lane B (B11): share the in-flight guard with the
1685            // SessionActor — the actor's apply pipeline can also
1686            // check it for symmetric guarantees, but the HANDLE-side
1687            // try_lock is what catches caller-side races (two
1688            // concurrent setPreferences requests).
1689            reconfig_in_flight,
1690            // M245 A1 — same ArcSwap the actor writes; this is the read side.
1691            snapshot,
1692        };
1693
1694        // M226 Step 6: spawn the watched-folder dispatcher AFTER the
1695        // SessionHandle is built so we can hand it a `.clone()`. The
1696        // dispatcher calls `handle.add_torrent(params)` for every
1697        // .torrent file dropped in `settings.watched_folder`. Drop of
1698        // the matching `watched_folder_shutdown_tx` on the actor (or
1699        // explicit send) signals the dispatcher to exit.
1700        let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1701            handle.clone(),
1702            watched_folder_settings_rx,
1703            watched_folder_changed,
1704            watched_folder_shutdown_rx,
1705        );
1706
1707        Ok(handle)
1708    }
1709
1710    /// Add a torrent from parsed .torrent metadata (v1, v2, or hybrid).
1711    ///
1712    /// Low-level entry point; used by the `irontide` facade and internal
1713    /// sim/test code. For user-facing adds, prefer
1714    /// [`add_torrent`](Self::add_torrent) with [`AddTorrentParams`],
1715    /// which resolves categories and `download_dir` via M170 semantics.
1716    ///
1717    /// # Errors
1718    ///
1719    /// Returns an error if the torrent cannot be added or the session is shut down.
1720    pub async fn add_torrent_with_meta(
1721        &self,
1722        meta: irontide_core::TorrentMeta,
1723        storage: Option<Arc<dyn TorrentStorage>>,
1724    ) -> crate::Result<Id20> {
1725        self.add_torrent_with_dir(meta, storage, None).await
1726    }
1727
1728    /// Unified add entry (M170).
1729    ///
1730    /// Resolves the download directory using the precedence documented
1731    /// on [`AddTorrentParams`] and the category registry, then delegates
1732    /// to the magnet or bytes-add path as appropriate. Returns the v1
1733    /// info hash of the new torrent.
1734    ///
1735    /// # Errors
1736    ///
1737    /// - [`Error::CategoryNotFound`](crate::Error::CategoryNotFound) when
1738    ///   `params.category` names a category that is not in the registry.
1739    /// - [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved)
1740    ///   when another task is currently deleting files for the same info
1741    ///   hash (mapped to 409 Conflict by the qBt API).
1742    /// - Propagates parsing errors for bad magnet URIs / .torrent bytes.
1743    /// - Propagates the existing `DuplicateTorrent` / `SessionAtCapacity`
1744    ///   error shapes unchanged.
1745    pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1746        let (tx, rx) = oneshot::channel();
1747        self.cmd_tx
1748            .send(SessionCommand::AddTorrentM170 {
1749                params: Box::new(params),
1750                reply: tx,
1751            })
1752            .await
1753            .map_err(|_| crate::Error::Shutdown)?;
1754        rx.await.map_err(|_| crate::Error::Shutdown)?
1755    }
1756
1757    /// Create a new qBt-compat category (M170).
1758    ///
1759    /// # Errors
1760    ///
1761    /// Returns [`CategoryError`](crate::CategoryError) on name validation
1762    /// failure, duplicate name, or persistence I/O error.
1763    pub async fn create_category(
1764        &self,
1765        name: String,
1766        save_path: PathBuf,
1767    ) -> Result<(), crate::category_manager::CategoryError> {
1768        let (tx, rx) = oneshot::channel();
1769        if self
1770            .cmd_tx
1771            .send(SessionCommand::CreateCategory {
1772                name,
1773                save_path,
1774                reply: tx,
1775            })
1776            .await
1777            .is_err()
1778        {
1779            return Err(crate::category_manager::CategoryError::Persistence(
1780                std::io::Error::other("session shutting down"),
1781            ));
1782        }
1783        rx.await.unwrap_or_else(|_| {
1784            Err(crate::category_manager::CategoryError::Persistence(
1785                std::io::Error::other("session shutting down"),
1786            ))
1787        })
1788    }
1789
1790    /// Update the `save_path` on an existing category (M170).
1791    ///
1792    /// # Errors
1793    ///
1794    /// Returns [`CategoryError::NotFound`](crate::CategoryError::NotFound)
1795    /// when the category does not exist, plus the same persistence /
1796    /// validation error shapes as [`create_category`](Self::create_category).
1797    pub async fn edit_category(
1798        &self,
1799        name: String,
1800        save_path: PathBuf,
1801    ) -> Result<(), crate::category_manager::CategoryError> {
1802        let (tx, rx) = oneshot::channel();
1803        if self
1804            .cmd_tx
1805            .send(SessionCommand::EditCategory {
1806                name,
1807                save_path,
1808                reply: tx,
1809            })
1810            .await
1811            .is_err()
1812        {
1813            return Err(crate::category_manager::CategoryError::Persistence(
1814                std::io::Error::other("session shutting down"),
1815            ));
1816        }
1817        rx.await.unwrap_or_else(|_| {
1818            Err(crate::category_manager::CategoryError::Persistence(
1819                std::io::Error::other("session shutting down"),
1820            ))
1821        })
1822    }
1823
1824    /// Remove zero or more categories (M170). Unknown names are tolerated
1825    /// (qBt behaviour). Returns the names that were actually removed.
1826    /// After removal, any torrent assigned to a removed category has its
1827    /// `category` label cleared and its resume data marked dirty.
1828    pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1829        let (tx, rx) = oneshot::channel();
1830        if self
1831            .cmd_tx
1832            .send(SessionCommand::RemoveCategories { names, reply: tx })
1833            .await
1834            .is_err()
1835        {
1836            return Vec::new();
1837        }
1838        rx.await.unwrap_or_default()
1839    }
1840
1841    /// Snapshot the current category list (M170).
1842    pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1843        let (tx, rx) = oneshot::channel();
1844        if self
1845            .cmd_tx
1846            .send(SessionCommand::ListCategories { reply: tx })
1847            .await
1848            .is_err()
1849        {
1850            return Vec::new();
1851        }
1852        rx.await.unwrap_or_default()
1853    }
1854
1855    /// List every tag name currently in the registry (M171). Sorted.
1856    pub async fn list_tags(&self) -> Vec<String> {
1857        let (tx, rx) = oneshot::channel();
1858        if self
1859            .cmd_tx
1860            .send(SessionCommand::ListTags { reply: tx })
1861            .await
1862            .is_err()
1863        {
1864            return Vec::new();
1865        }
1866        rx.await.unwrap_or_default()
1867    }
1868
1869    /// Create a batch of tags (M171). Returns one
1870    /// `Result<(), TagError>` per requested name so the caller can
1871    /// distinguish which names already existed and which were newly
1872    /// created. Persistence is best-effort on success; any partial
1873    /// persistence failure warns but does not change the per-call reply.
1874    pub async fn create_tags(
1875        &self,
1876        names: Vec<String>,
1877    ) -> Vec<Result<(), crate::tag_manager::TagError>> {
1878        let (tx, rx) = oneshot::channel();
1879        if self
1880            .cmd_tx
1881            .send(SessionCommand::CreateTags { names, reply: tx })
1882            .await
1883            .is_err()
1884        {
1885            return Vec::new();
1886        }
1887        rx.await.unwrap_or_default()
1888    }
1889
1890    /// Delete a batch of tags (M171). Returns the subset of names that
1891    /// were actually present at call time (unknown names are silently
1892    /// skipped, matching qBt's idempotent `deleteTags`).
1893    pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
1894        let (tx, rx) = oneshot::channel();
1895        if self
1896            .cmd_tx
1897            .send(SessionCommand::DeleteTags { names, reply: tx })
1898            .await
1899            .is_err()
1900        {
1901            return Vec::new();
1902        }
1903        rx.await.unwrap_or_default()
1904    }
1905
1906    /// Add the given tags to each torrent in `hashes` (M171).
1907    /// Idempotent — tags that a torrent already has are left alone.
1908    /// Unknown info hashes are silently skipped.
1909    ///
1910    /// # Errors
1911    ///
1912    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
1913    /// session's command channel has closed.
1914    pub async fn add_tags_to_torrents(
1915        &self,
1916        hashes: Vec<Id20>,
1917        tags: Vec<String>,
1918    ) -> crate::Result<()> {
1919        let (tx, rx) = oneshot::channel();
1920        self.cmd_tx
1921            .send(SessionCommand::AddTagsToTorrents {
1922                info_hashes: hashes,
1923                tags,
1924                reply: tx,
1925            })
1926            .await
1927            .map_err(|_| crate::Error::Shutdown)?;
1928        rx.await.map_err(|_| crate::Error::Shutdown)?
1929    }
1930
1931    /// Remove the given tags from each torrent in `hashes` (M171).
1932    /// Unknown info hashes are silently skipped.
1933    ///
1934    /// # Errors
1935    ///
1936    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
1937    /// session's command channel has closed.
1938    pub async fn remove_tags_from_torrents(
1939        &self,
1940        hashes: Vec<Id20>,
1941        tags: Vec<String>,
1942    ) -> crate::Result<()> {
1943        let (tx, rx) = oneshot::channel();
1944        self.cmd_tx
1945            .send(SessionCommand::RemoveTagsFromTorrents {
1946                info_hashes: hashes,
1947                tags,
1948                reply: tx,
1949            })
1950            .await
1951            .map_err(|_| crate::Error::Shutdown)?;
1952        rx.await.map_err(|_| crate::Error::Shutdown)?
1953    }
1954
1955    /// Remove a torrent and delete its on-disk files (M170,
1956    /// `deleteFiles=true`).
1957    ///
1958    /// Pauses the torrent, closes storage handles, then dispatches a
1959    /// `spawn_blocking` file-removal walk via
1960    /// [`delete_torrent_files_sync`](irontide_storage::delete_torrent_files_sync).
1961    /// The info hash is placed in a deletion grace set for the duration of
1962    /// the walk; concurrent calls to [`add_torrent`](Self::add_torrent)
1963    /// with the same hash return [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved).
1964    ///
1965    /// # Errors
1966    ///
1967    /// Returns [`Error::TorrentNotFound`](crate::Error::TorrentNotFound)
1968    /// if the info hash is not in the session. I/O failures during file
1969    /// removal are logged and swallowed — always returns `Ok(())` when
1970    /// the torrent is found.
1971    pub async fn remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
1972        let (tx, rx) = oneshot::channel();
1973        self.cmd_tx
1974            .send(SessionCommand::RemoveTorrentWithFiles {
1975                info_hash,
1976                reply: tx,
1977            })
1978            .await
1979            .map_err(|_| crate::Error::Shutdown)?;
1980        rx.await.map_err(|_| crate::Error::Shutdown)?
1981    }
1982
1983    /// Add a torrent with an optional per-torrent download directory override.
1984    ///
1985    /// # Errors
1986    ///
1987    /// Returns an error if the torrent cannot be added or the session is shut down.
1988    pub async fn add_torrent_with_dir(
1989        &self,
1990        meta: irontide_core::TorrentMeta,
1991        storage: Option<Arc<dyn TorrentStorage>>,
1992        download_dir: Option<PathBuf>,
1993    ) -> crate::Result<Id20> {
1994        let (tx, rx) = oneshot::channel();
1995        self.cmd_tx
1996            .send(SessionCommand::AddTorrent {
1997                meta: Box::new(meta),
1998                storage,
1999                download_dir,
2000                reply: tx,
2001            })
2002            .await
2003            .map_err(|_| crate::Error::Shutdown)?;
2004        rx.await.map_err(|_| crate::Error::Shutdown)?
2005    }
2006
2007    /// Add a torrent from a magnet link (metadata fetched via BEP 9).
2008    ///
2009    /// # Errors
2010    ///
2011    /// Returns an error if the torrent cannot be added or the session is shut down.
2012    pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2013        self.add_magnet_with_dir(magnet, None).await
2014    }
2015
2016    /// Add a magnet link with an optional per-torrent download directory override.
2017    ///
2018    /// # Errors
2019    ///
2020    /// Returns an error if the torrent cannot be added or the session is shut down.
2021    pub async fn add_magnet_with_dir(
2022        &self,
2023        magnet: Magnet,
2024        download_dir: Option<PathBuf>,
2025    ) -> crate::Result<Id20> {
2026        let (tx, rx) = oneshot::channel();
2027        self.cmd_tx
2028            .send(SessionCommand::AddMagnet {
2029                magnet,
2030                download_dir,
2031                reply: tx,
2032            })
2033            .await
2034            .map_err(|_| crate::Error::Shutdown)?;
2035        rx.await.map_err(|_| crate::Error::Shutdown)?
2036    }
2037
2038    /// Remove a torrent from the session.
2039    ///
2040    /// # Errors
2041    ///
2042    /// Returns an error if the torrent is not found or the session is shut down.
2043    pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2044        let (tx, rx) = oneshot::channel();
2045        self.cmd_tx
2046            .send(SessionCommand::RemoveTorrent {
2047                info_hash,
2048                reply: tx,
2049            })
2050            .await
2051            .map_err(|_| crate::Error::Shutdown)?;
2052        rx.await.map_err(|_| crate::Error::Shutdown)?
2053    }
2054
2055    /// Pause a torrent.
2056    ///
2057    /// # Errors
2058    ///
2059    /// Returns an error if the session is shut down.
2060    pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2061        let (tx, rx) = oneshot::channel();
2062        self.cmd_tx
2063            .send(SessionCommand::PauseTorrent {
2064                info_hash,
2065                reply: tx,
2066            })
2067            .await
2068            .map_err(|_| crate::Error::Shutdown)?;
2069        rx.await.map_err(|_| crate::Error::Shutdown)?
2070    }
2071
2072    /// Resume a paused torrent.
2073    ///
2074    /// # Errors
2075    ///
2076    /// Returns an error if the session is shut down.
2077    pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2078        let (tx, rx) = oneshot::channel();
2079        self.cmd_tx
2080            .send(SessionCommand::ResumeTorrent {
2081                info_hash,
2082                reply: tx,
2083            })
2084            .await
2085            .map_err(|_| crate::Error::Shutdown)?;
2086        rx.await.map_err(|_| crate::Error::Shutdown)?
2087    }
2088
2089    /// Force-resume a torrent, bypassing queue limits.
2090    ///
2091    /// # Errors
2092    ///
2093    /// Returns an error if the session is shut down.
2094    pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2095        let (tx, rx) = oneshot::channel();
2096        self.cmd_tx
2097            .send(SessionCommand::ForceResumeTorrent {
2098                info_hash,
2099                reply: tx,
2100            })
2101            .await
2102            .map_err(|_| crate::Error::Shutdown)?;
2103        rx.await.map_err(|_| crate::Error::Shutdown)?
2104    }
2105
2106    /// Set a per-torrent seed ratio limit override (`None` = use session default).
2107    ///
2108    /// # Errors
2109    ///
2110    /// Returns an error if the session is shut down.
2111    pub async fn set_torrent_seed_ratio(
2112        &self,
2113        info_hash: Id20,
2114        limit: Option<f64>,
2115    ) -> crate::Result<()> {
2116        let (tx, rx) = oneshot::channel();
2117        self.cmd_tx
2118            .send(SessionCommand::SetTorrentSeedRatio {
2119                info_hash,
2120                limit,
2121                reply: tx,
2122            })
2123            .await
2124            .map_err(|_| crate::Error::Shutdown)?;
2125        rx.await.map_err(|_| crate::Error::Shutdown)?
2126    }
2127
2128    /// Get statistics for a specific torrent.
2129    ///
2130    /// # Errors
2131    ///
2132    /// Returns an error if the session is shut down.
2133    pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2134        let (tx, rx) = oneshot::channel();
2135        self.cmd_tx
2136            .send(SessionCommand::TorrentStats {
2137                info_hash,
2138                reply: tx,
2139            })
2140            .await
2141            .map_err(|_| crate::Error::Shutdown)?;
2142        rx.await.map_err(|_| crate::Error::Shutdown)?
2143    }
2144
2145    /// Snapshot per-peer cumulative unchoke duration for the given torrent.
2146    ///
2147    /// Returns `Ok(Some(map))` when the torrent exists. Each entry is the
2148    /// total time we (this session) had that peer unchoked over the
2149    /// torrent's lifetime, including reconnects (durations are flushed
2150    /// into a per-(SocketAddr × torrent) map on disconnect and re-merged
2151    /// here at query time).
2152    ///
2153    /// Returns `Ok(None)` when the torrent is unknown to this session —
2154    /// the explicit contract distinguishes "torrent missing" from "exists
2155    /// but no peers were ever unchoked".
2156    ///
2157    /// # Errors
2158    ///
2159    /// Returns an error if the session is shut down.
2160    pub async fn peer_unchoke_durations(
2161        &self,
2162        info_hash: Id20,
2163    ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2164        let (tx, rx) = oneshot::channel();
2165        self.cmd_tx
2166            .send(SessionCommand::QueryUnchokeDurations {
2167                info_hash,
2168                reply: tx,
2169            })
2170            .await
2171            .map_err(|_| crate::Error::Shutdown)?;
2172        rx.await.map_err(|_| crate::Error::Shutdown)
2173    }
2174
2175    /// Get metadata info for a specific torrent.
2176    ///
2177    /// # Errors
2178    ///
2179    /// Returns an error if the session is shut down.
2180    pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2181        let (tx, rx) = oneshot::channel();
2182        self.cmd_tx
2183            .send(SessionCommand::TorrentInfo {
2184                info_hash,
2185                reply: tx,
2186            })
2187            .await
2188            .map_err(|_| crate::Error::Shutdown)?;
2189        rx.await.map_err(|_| crate::Error::Shutdown)?
2190    }
2191
2192    /// List all active torrent info hashes.
2193    ///
2194    /// # Errors
2195    ///
2196    /// Returns an error if the session is shut down.
2197    pub async fn list_torrents(&self) -> crate::Result<Vec<Id20>> {
2198        let (tx, rx) = oneshot::channel();
2199        self.cmd_tx
2200            .send(SessionCommand::ListTorrents { reply: tx })
2201            .await
2202            .map_err(|_| crate::Error::Shutdown)?;
2203        rx.await.map_err(|_| crate::Error::Shutdown)
2204    }
2205
2206    /// Get aggregate session statistics.
2207    ///
2208    /// # Errors
2209    ///
2210    /// Returns an error if the session is shut down.
2211    pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2212        let (tx, rx) = oneshot::channel();
2213        self.cmd_tx
2214            .send(SessionCommand::SessionStats { reply: tx })
2215            .await
2216            .map_err(|_| crate::Error::Shutdown)?;
2217        rx.await.map_err(|_| crate::Error::Shutdown)
2218    }
2219
2220    /// Collect per-torrent and per-peer debug state for diagnosing dispatch
2221    /// throughput regressions (M187).
2222    ///
2223    /// Individual torrents that do not respond within 500 ms are skipped so
2224    /// the endpoint always returns partial results rather than failing.
2225    ///
2226    /// # Errors
2227    ///
2228    /// Returns an error if the session is shut down.
2229    pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2230        let (tx, rx) = oneshot::channel();
2231        self.cmd_tx
2232            .send(SessionCommand::DebugState { reply: tx })
2233            .await
2234            .map_err(|_| crate::Error::Shutdown)?;
2235        // Use a generous 5 s overall timeout — the per-torrent timeouts are
2236        // 500 ms inside the actor, but with many torrents the total can add up.
2237        tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2238            .await
2239            .map_err(|_| crate::Error::Shutdown)?
2240            .map_err(|_| crate::Error::Shutdown)
2241    }
2242
2243    /// Subscribe to all alerts passing the session-level mask.
2244    #[must_use]
2245    pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2246        self.alert_tx.subscribe()
2247    }
2248
2249    /// Subscribe with per-subscriber category filtering.
2250    #[must_use]
2251    pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2252        AlertStream::new(self.alert_tx.subscribe(), filter)
2253    }
2254
2255    /// Trigger an immediate session stats snapshot and alert.
2256    ///
2257    /// # Errors
2258    ///
2259    /// Returns an error if the session is shut down.
2260    pub async fn post_session_stats(&self) -> crate::Result<()> {
2261        self.cmd_tx
2262            .send(SessionCommand::PostSessionStats)
2263            .await
2264            .map_err(|_| crate::Error::Shutdown)
2265    }
2266
2267    /// Access the shared atomic counters (read-only handle).
2268    #[must_use]
2269    pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2270        &self.counters
2271    }
2272
2273    /// Atomically update the session-level alert mask.
2274    pub fn set_alert_mask(&self, mask: AlertCategory) {
2275        self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2276    }
2277
2278    /// Read the current session-level alert mask.
2279    #[must_use]
2280    pub fn alert_mask(&self) -> AlertCategory {
2281        AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2282    }
2283
2284    /// Add peers to a specific torrent by info hash.
2285    ///
2286    /// # Errors
2287    ///
2288    /// Returns an error if the session is shut down.
2289    pub async fn add_peers(
2290        &self,
2291        info_hash: Id20,
2292        peers: Vec<SocketAddr>,
2293        source: crate::peer_state::PeerSource,
2294    ) -> crate::Result<()> {
2295        let (tx, rx) = oneshot::channel();
2296        self.cmd_tx
2297            .send(SessionCommand::AddPeers {
2298                info_hash,
2299                peers,
2300                source,
2301                reply: tx,
2302            })
2303            .await
2304            .map_err(|_| crate::Error::Shutdown)?;
2305        rx.await.map_err(|_| crate::Error::Shutdown)?
2306    }
2307
2308    /// Gracefully shut down the session and all torrents.
2309    ///
2310    /// # Errors
2311    ///
2312    /// Returns an error if the session is shut down.
2313    pub async fn shutdown(&self) -> crate::Result<()> {
2314        // Timeout prevents hang if SessionActor is processing a heavy batch
2315        let _ = tokio::time::timeout(
2316            std::time::Duration::from_secs(10),
2317            self.cmd_tx.send(SessionCommand::Shutdown),
2318        )
2319        .await;
2320        Ok(())
2321    }
2322
2323    /// Save resume data for a specific torrent.
2324    ///
2325    /// # Errors
2326    ///
2327    /// Returns an error if the I/O operation fails.
2328    pub async fn save_torrent_resume_data(
2329        &self,
2330        info_hash: Id20,
2331    ) -> crate::Result<irontide_core::FastResumeData> {
2332        let (tx, rx) = oneshot::channel();
2333        self.cmd_tx
2334            .send(SessionCommand::SaveTorrentResumeData {
2335                info_hash,
2336                reply: tx,
2337            })
2338            .await
2339            .map_err(|_| crate::Error::Shutdown)?;
2340        rx.await.map_err(|_| crate::Error::Shutdown)?
2341    }
2342
2343    /// Save full session state (all torrent resume data + DHT node cache).
2344    ///
2345    /// # Errors
2346    ///
2347    /// Returns an error if the I/O operation fails.
2348    pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2349        let (tx, rx) = oneshot::channel();
2350        self.cmd_tx
2351            .send(SessionCommand::SaveSessionState { reply: tx })
2352            .await
2353            .map_err(|_| crate::Error::Shutdown)?;
2354        rx.await.map_err(|_| crate::Error::Shutdown)?
2355    }
2356
2357    /// Load and restore torrents from per-torrent resume files on disk.
2358    ///
2359    /// Scans the resume directory for `.resume` files, deserializes each one,
2360    /// reconstructs the torrent metadata, and re-adds it to the session. For
2361    /// resolved torrents (with stored info dict), the piece bitmap is restored.
2362    /// Unresolved magnets are re-added as magnet links.
2363    ///
2364    /// # Errors
2365    ///
2366    /// Returns [`crate::Error::Shutdown`] if the session has been shut down.
2367    pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2368        let (tx, rx) = oneshot::channel();
2369        self.cmd_tx
2370            .send(SessionCommand::LoadResumeState { reply: tx })
2371            .await
2372            .map_err(|_| crate::Error::Shutdown)?;
2373        rx.await.map_err(|_| crate::Error::Shutdown)?
2374    }
2375
2376    /// Save per-torrent resume files for all dirty torrents.
2377    ///
2378    /// Iterates every torrent in the session, checks the `need_save_resume`
2379    /// dirty flag, serializes resume data to disk, and clears the flag.
2380    /// Returns the number of files written.
2381    ///
2382    /// # Errors
2383    ///
2384    /// Returns [`Error::Shutdown`] if the session actor has stopped.
2385    pub async fn save_resume_state(&self) -> crate::Result<usize> {
2386        let (tx, rx) = oneshot::channel();
2387        self.cmd_tx
2388            .send(SessionCommand::SaveResumeState { reply: tx })
2389            .await
2390            .map_err(|_| crate::Error::Shutdown)?;
2391        rx.await.map_err(|_| crate::Error::Shutdown)?
2392    }
2393
2394    /// Get the queue position of a torrent. Returns -1 if not auto-managed.
2395    ///
2396    /// # Errors
2397    ///
2398    /// Returns an error if the session is shut down.
2399    pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2400        let (tx, rx) = oneshot::channel();
2401        self.cmd_tx
2402            .send(SessionCommand::QueuePosition {
2403                info_hash,
2404                reply: tx,
2405            })
2406            .await
2407            .map_err(|_| crate::Error::Shutdown)?;
2408        rx.await.map_err(|_| crate::Error::Shutdown)?
2409    }
2410
2411    /// Set the absolute queue position of a torrent. Shifts other torrents.
2412    ///
2413    /// # Errors
2414    ///
2415    /// Returns an error if the session is shut down.
2416    pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2417        let (tx, rx) = oneshot::channel();
2418        self.cmd_tx
2419            .send(SessionCommand::SetQueuePosition {
2420                info_hash,
2421                pos,
2422                reply: tx,
2423            })
2424            .await
2425            .map_err(|_| crate::Error::Shutdown)?;
2426        rx.await.map_err(|_| crate::Error::Shutdown)?
2427    }
2428
2429    /// Move a torrent one position up (lower number = higher priority).
2430    ///
2431    /// # Errors
2432    ///
2433    /// Returns an error if the session is shut down.
2434    pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2435        let (tx, rx) = oneshot::channel();
2436        self.cmd_tx
2437            .send(SessionCommand::QueuePositionUp {
2438                info_hash,
2439                reply: tx,
2440            })
2441            .await
2442            .map_err(|_| crate::Error::Shutdown)?;
2443        rx.await.map_err(|_| crate::Error::Shutdown)?
2444    }
2445
2446    /// Move a torrent one position down.
2447    ///
2448    /// # Errors
2449    ///
2450    /// Returns an error if the session is shut down.
2451    pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2452        let (tx, rx) = oneshot::channel();
2453        self.cmd_tx
2454            .send(SessionCommand::QueuePositionDown {
2455                info_hash,
2456                reply: tx,
2457            })
2458            .await
2459            .map_err(|_| crate::Error::Shutdown)?;
2460        rx.await.map_err(|_| crate::Error::Shutdown)?
2461    }
2462
2463    /// Move a torrent to position 0 (highest priority).
2464    ///
2465    /// # Errors
2466    ///
2467    /// Returns an error if the session is shut down.
2468    pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2469        let (tx, rx) = oneshot::channel();
2470        self.cmd_tx
2471            .send(SessionCommand::QueuePositionTop {
2472                info_hash,
2473                reply: tx,
2474            })
2475            .await
2476            .map_err(|_| crate::Error::Shutdown)?;
2477        rx.await.map_err(|_| crate::Error::Shutdown)?
2478    }
2479
2480    /// Move a torrent to the last position (lowest priority).
2481    ///
2482    /// # Errors
2483    ///
2484    /// Returns an error if the session is shut down.
2485    pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2486        let (tx, rx) = oneshot::channel();
2487        self.cmd_tx
2488            .send(SessionCommand::QueuePositionBottom {
2489                info_hash,
2490                reply: tx,
2491            })
2492            .await
2493            .map_err(|_| crate::Error::Shutdown)?;
2494        rx.await.map_err(|_| crate::Error::Shutdown)?
2495    }
2496
2497    /// Ban a peer IP session-wide. All torrents will disconnect and refuse this IP.
2498    ///
2499    /// # Errors
2500    ///
2501    /// Returns an error if the session is shut down.
2502    pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2503        let (tx, rx) = oneshot::channel();
2504        self.cmd_tx
2505            .send(SessionCommand::BanPeer { ip, reply: tx })
2506            .await
2507            .map_err(|_| crate::Error::Shutdown)?;
2508        rx.await.map_err(|_| crate::Error::Shutdown)
2509    }
2510
2511    /// Remove a ban and clear strikes for an IP. Returns `true` if the IP was banned.
2512    ///
2513    /// # Errors
2514    ///
2515    /// Returns an error if the session is shut down.
2516    pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2517        let (tx, rx) = oneshot::channel();
2518        self.cmd_tx
2519            .send(SessionCommand::UnbanPeer { ip, reply: tx })
2520            .await
2521            .map_err(|_| crate::Error::Shutdown)?;
2522        rx.await.map_err(|_| crate::Error::Shutdown)
2523    }
2524
2525    /// Replace the session-wide IP filter. Connected peers that are now blocked will
2526    /// be refused on subsequent connection attempts.
2527    ///
2528    /// # Errors
2529    ///
2530    /// Returns an error if the session is shut down.
2531    pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2532        let (tx, rx) = oneshot::channel();
2533        self.cmd_tx
2534            .send(SessionCommand::SetIpFilter { filter, reply: tx })
2535            .await
2536            .map_err(|_| crate::Error::Shutdown)?;
2537        rx.await.map_err(|_| crate::Error::Shutdown)
2538    }
2539
2540    /// Get a clone of the current IP filter.
2541    ///
2542    /// # Errors
2543    ///
2544    /// Returns an error if the session is shut down.
2545    pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2546        let (tx, rx) = oneshot::channel();
2547        self.cmd_tx
2548            .send(SessionCommand::GetIpFilter { reply: tx })
2549            .await
2550            .map_err(|_| crate::Error::Shutdown)?;
2551        rx.await.map_err(|_| crate::Error::Shutdown)
2552    }
2553
2554    /// Get a clone of the current session settings.
2555    ///
2556    /// # Errors
2557    ///
2558    /// Returns an error if the session is shut down.
2559    pub async fn settings(&self) -> crate::Result<Settings> {
2560        let (tx, rx) = oneshot::channel();
2561        self.cmd_tx
2562            .send(SessionCommand::GetSettings { reply: tx })
2563            .await
2564            .map_err(|_| crate::Error::Shutdown)?;
2565        rx.await.map_err(|_| crate::Error::Shutdown)
2566    }
2567
2568    /// Apply new settings at runtime.
2569    ///
2570    /// Validates the settings, updates rate limiters immediately, and stores
2571    /// the new settings. Sub-actor reconfiguration (disk, DHT, NAT) takes
2572    /// effect on next session restart for the fields that remain in
2573    /// `restart_required`; `listen_port`, `dht`, `lsd` are now applied
2574    /// live (M173 Lane B B10).
2575    ///
2576    /// # Errors
2577    ///
2578    /// Returns [`crate::Error::ConcurrentReconfig`] if another
2579    /// `apply_settings` / `apply_settings_classified` call is still in
2580    /// flight (M173 Lane B B11). Caller should retry shortly.
2581    pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2582        let _guard = self
2583            .reconfig_in_flight
2584            .try_lock()
2585            .ok_or(crate::Error::ConcurrentReconfig)?;
2586        let (tx, rx) = oneshot::channel();
2587        self.cmd_tx
2588            .send(SessionCommand::ApplySettings {
2589                settings: Box::new(settings),
2590                reply: tx,
2591            })
2592            .await
2593            .map_err(|_| crate::Error::Shutdown)?;
2594        rx.await.map_err(|_| crate::Error::Shutdown)?
2595    }
2596
2597    /// Apply new settings and return a classification of which fields took
2598    /// effect immediately versus which require a session restart (M171 D3.5).
2599    ///
2600    /// Identical behaviour to [`apply_settings`](Self::apply_settings), but
2601    /// the return value lets callers surface the "restart to apply" UX —
2602    /// specifically the `X-IronTide-Restart-Pending` response header on the
2603    /// qBt v2 `setPreferences` endpoint and the GUI restart-banner.
2604    ///
2605    /// M173 Lane B (B11): the in-flight guard is acquired BEFORE the
2606    /// snapshot read, so concurrent callers cannot race the
2607    /// read-modify-write. The second caller hits
2608    /// [`crate::Error::ConcurrentReconfig`].
2609    ///
2610    /// # Errors
2611    ///
2612    /// Returns [`crate::Error::ConcurrentReconfig`] if another reconfig
2613    /// is still in flight. Returns [`crate::Error::InvalidSettings`] if
2614    /// the patch fails validation. Returns [`crate::Error::Shutdown`]
2615    /// if the session has shut down.
2616    pub async fn apply_settings_classified(
2617        &self,
2618        settings: Settings,
2619    ) -> crate::Result<AppliedSettings> {
2620        let _guard = self
2621            .reconfig_in_flight
2622            .try_lock()
2623            .ok_or(crate::Error::ConcurrentReconfig)?;
2624        // Snapshot AFTER acquiring the guard so the classification is
2625        // computed against a stable pre-call state.
2626        let snapshot = self.settings().await?;
2627        let immediate = classify_immediate(&snapshot, &settings);
2628        let restart_required = classify_restart_required(&snapshot, &settings);
2629        // Inner apply_settings tries to re-acquire the guard. To avoid
2630        // a self-deadlock, we send the command directly here rather
2631        // than calling self.apply_settings(), which would error out
2632        // with ConcurrentReconfig because we already hold the lock.
2633        let (tx, rx) = oneshot::channel();
2634        self.cmd_tx
2635            .send(SessionCommand::ApplySettings {
2636                settings: Box::new(settings),
2637                reply: tx,
2638            })
2639            .await
2640            .map_err(|_| crate::Error::Shutdown)?;
2641        rx.await.map_err(|_| crate::Error::Shutdown)??;
2642        Ok(AppliedSettings {
2643            immediate,
2644            restart_required,
2645        })
2646    }
2647
2648    /// Get the current DHT routing-table size, summed across IPv4 and IPv6
2649    /// instances (M171 D4).
2650    ///
2651    /// Returns `Ok(0)` when DHT is disabled for both address families, or
2652    /// when neither instance has bootstrapped yet. Wired into the qBt v2
2653    /// `transferInfo.dht_nodes` field and the DHT pseudo-tracker's
2654    /// `num_peers` column on `/api/v2/torrents/trackers`.
2655    ///
2656    /// # Errors
2657    ///
2658    /// Returns an error if the session is shut down.
2659    pub async fn dht_node_count(&self) -> crate::Result<usize> {
2660        let (tx, rx) = oneshot::channel();
2661        self.cmd_tx
2662            .send(SessionCommand::DhtNodeCount { reply: tx })
2663            .await
2664            .map_err(|_| crate::Error::Shutdown)?;
2665        rx.await.map_err(|_| crate::Error::Shutdown)
2666    }
2667
2668    /// Get the list of currently banned peer IPs.
2669    ///
2670    /// # Errors
2671    ///
2672    /// Returns an error if the session is shut down.
2673    pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2674        let (tx, rx) = oneshot::channel();
2675        self.cmd_tx
2676            .send(SessionCommand::BannedPeers { reply: tx })
2677            .await
2678            .map_err(|_| crate::Error::Shutdown)?;
2679        rx.await.map_err(|_| crate::Error::Shutdown)
2680    }
2681
2682    /// Move a torrent's data files to a new download directory.
2683    ///
2684    /// # Errors
2685    ///
2686    /// Returns an error if the session is shut down.
2687    pub async fn move_torrent_storage(
2688        &self,
2689        info_hash: Id20,
2690        new_path: std::path::PathBuf,
2691    ) -> crate::Result<()> {
2692        let (tx, rx) = oneshot::channel();
2693        self.cmd_tx
2694            .send(SessionCommand::MoveTorrentStorage {
2695                info_hash,
2696                new_path,
2697                reply: tx,
2698            })
2699            .await
2700            .map_err(|_| crate::Error::Shutdown)?;
2701        rx.await.map_err(|_| crate::Error::Shutdown)?
2702    }
2703
2704    /// Opens a file stream for sequential reading (`AsyncRead` + `AsyncSeek`).
2705    ///
2706    /// The returned [`FileStream`](crate::streaming::FileStream) reads data from
2707    /// a specific file within a torrent, blocking on pieces that haven't been
2708    /// downloaded yet.
2709    ///
2710    /// # Errors
2711    ///
2712    /// Returns an error if the session is shut down.
2713    pub async fn open_file(
2714        &self,
2715        info_hash: Id20,
2716        file_index: usize,
2717    ) -> crate::Result<crate::streaming::FileStream> {
2718        let (tx, rx) = oneshot::channel();
2719        self.cmd_tx
2720            .send(SessionCommand::OpenFile {
2721                info_hash,
2722                file_index,
2723                reply: tx,
2724            })
2725            .await
2726            .map_err(|_| crate::Error::Shutdown)?;
2727        rx.await.map_err(|_| crate::Error::Shutdown)?
2728    }
2729
2730    /// Force all trackers for a torrent to re-announce immediately.
2731    ///
2732    /// # Errors
2733    ///
2734    /// Returns an error if the session is shut down.
2735    pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2736        let (tx, rx) = oneshot::channel();
2737        self.cmd_tx
2738            .send(SessionCommand::ForceReannounce {
2739                info_hash,
2740                reply: tx,
2741            })
2742            .await
2743            .map_err(|_| crate::Error::Shutdown)?;
2744        rx.await.map_err(|_| crate::Error::Shutdown)?
2745    }
2746
2747    /// Get the list of all configured trackers with their status for a torrent.
2748    ///
2749    /// # Errors
2750    ///
2751    /// Returns an error if the session is shut down.
2752    pub async fn tracker_list(
2753        &self,
2754        info_hash: Id20,
2755    ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2756        let (tx, rx) = oneshot::channel();
2757        self.cmd_tx
2758            .send(SessionCommand::TrackerList {
2759                info_hash,
2760                reply: tx,
2761            })
2762            .await
2763            .map_err(|_| crate::Error::Shutdown)?;
2764        rx.await.map_err(|_| crate::Error::Shutdown)?
2765    }
2766
2767    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2768    /// PEX (BEP 11) for this torrent since the actor started.
2769    ///
2770    /// # Errors
2771    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2772    ///   the hash is unknown.
2773    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2774    pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2775        let counts = self.peer_source_counts(info_hash).await?;
2776        Ok(counts.0)
2777    }
2778
2779    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2780    /// LSD (BEP 14) multicast for this torrent since the actor started.
2781    ///
2782    /// # Errors
2783    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2784    ///   the hash is unknown.
2785    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2786    pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2787        let counts = self.peer_source_counts(info_hash).await?;
2788        Ok(counts.1)
2789    }
2790
2791    async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2792        let (tx, rx) = oneshot::channel();
2793        self.cmd_tx
2794            .send(SessionCommand::GetPeerSourceCounts {
2795                info_hash,
2796                reply: tx,
2797            })
2798            .await
2799            .map_err(|_| crate::Error::Shutdown)?;
2800        rx.await.map_err(|_| crate::Error::Shutdown)?
2801    }
2802
2803    /// M178 Lane C: per-URL web-seed stats snapshot for the qBt v2
2804    /// webseeds endpoint and the GUI HTTP Sources tab.
2805    ///
2806    /// # Errors
2807    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2808    ///   the hash is unknown.
2809    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2810    pub async fn web_seed_stats(
2811        &self,
2812        info_hash: Id20,
2813    ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2814        let (tx, rx) = oneshot::channel();
2815        self.cmd_tx
2816            .send(SessionCommand::GetWebSeedStats {
2817                info_hash,
2818                reply: tx,
2819            })
2820            .await
2821            .map_err(|_| crate::Error::Shutdown)?;
2822        rx.await.map_err(|_| crate::Error::Shutdown)?
2823    }
2824
2825    /// M171 Lane B: list the web seed URLs (BEP 19 + BEP 17 merged) for a torrent.
2826    ///
2827    /// BEP 19 `url-list` URLs come first, followed by BEP 17 `httpseeds`
2828    /// URLs — the wire order. Returns an empty vec when the torrent's
2829    /// metadata has not yet been fetched (magnet still resolving the
2830    /// info dictionary).
2831    ///
2832    /// # Errors
2833    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2834    ///   info-hash is unknown.
2835    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2836    ///   command channel has closed.
2837    pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
2838        let (tx, rx) = oneshot::channel();
2839        self.cmd_tx
2840            .send(SessionCommand::GetWebSeeds {
2841                info_hash,
2842                reply: tx,
2843            })
2844            .await
2845            .map_err(|_| crate::Error::Shutdown)?;
2846        rx.await.map_err(|_| crate::Error::Shutdown)?
2847    }
2848
2849    /// M171 Lane B: snapshot the per-piece qBt state codes for a torrent.
2850    ///
2851    /// Returns a `Vec<u8>` one byte per piece — `0` = not downloaded,
2852    /// `1` = downloading, `2` = downloaded + checked. An empty vec is
2853    /// returned when metadata hasn't resolved yet (magnet still fetching
2854    /// the info dictionary) — callers should map that to a 404.
2855    ///
2856    /// # Errors
2857    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2858    ///   info-hash is unknown.
2859    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2860    ///   command channel has closed.
2861    pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
2862        let (tx, rx) = oneshot::channel();
2863        self.cmd_tx
2864            .send(SessionCommand::GetPieceStates {
2865                info_hash,
2866                reply: tx,
2867            })
2868            .await
2869            .map_err(|_| crate::Error::Shutdown)?;
2870        rx.await.map_err(|_| crate::Error::Shutdown)?
2871    }
2872
2873    /// M171 Lane B: paginated piece hash list for a torrent.
2874    ///
2875    /// v1 / hybrid torrents return SHA-1 hashes (40-char hex strings);
2876    /// v2-only torrents return SHA-256 hashes (64-char hex strings).
2877    /// `offset` and `limit` are clamped to the real hash count inside
2878    /// the actor — callers can pass arbitrary values without overflow
2879    /// concerns. An empty vec is returned when metadata hasn't resolved
2880    /// yet — callers should map that to a 404.
2881    ///
2882    /// # Errors
2883    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
2884    ///   info-hash is unknown.
2885    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
2886    ///   command channel has closed.
2887    pub async fn get_piece_hashes(
2888        &self,
2889        info_hash: Id20,
2890        offset: u32,
2891        limit: u32,
2892    ) -> crate::Result<Vec<String>> {
2893        let (tx, rx) = oneshot::channel();
2894        self.cmd_tx
2895            .send(SessionCommand::GetPieceHashes {
2896                info_hash,
2897                offset,
2898                limit,
2899                reply: tx,
2900            })
2901            .await
2902            .map_err(|_| crate::Error::Shutdown)?;
2903        rx.await.map_err(|_| crate::Error::Shutdown)?
2904    }
2905
2906    /// Scrape trackers for seeder/leecher counts for a torrent.
2907    ///
2908    /// # Errors
2909    ///
2910    /// Returns an error if the session is shut down.
2911    pub async fn scrape(
2912        &self,
2913        info_hash: Id20,
2914    ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
2915        let (tx, rx) = oneshot::channel();
2916        self.cmd_tx
2917            .send(SessionCommand::Scrape {
2918                info_hash,
2919                reply: tx,
2920            })
2921            .await
2922            .map_err(|_| crate::Error::Shutdown)?;
2923        rx.await.map_err(|_| crate::Error::Shutdown)?
2924    }
2925
2926    /// Set the download priority of a specific file within a torrent.
2927    ///
2928    /// # Errors
2929    ///
2930    /// Returns an error if the session is shut down.
2931    pub async fn set_file_priority(
2932        &self,
2933        info_hash: Id20,
2934        index: usize,
2935        priority: irontide_core::FilePriority,
2936    ) -> crate::Result<()> {
2937        let (tx, rx) = oneshot::channel();
2938        self.cmd_tx
2939            .send(SessionCommand::SetFilePriority {
2940                info_hash,
2941                index,
2942                priority,
2943                reply: tx,
2944            })
2945            .await
2946            .map_err(|_| crate::Error::Shutdown)?;
2947        rx.await.map_err(|_| crate::Error::Shutdown)?
2948    }
2949
2950    /// Get the current per-file priorities for a torrent.
2951    ///
2952    /// # Errors
2953    ///
2954    /// Returns an error if the session is shut down.
2955    pub async fn file_priorities(
2956        &self,
2957        info_hash: Id20,
2958    ) -> crate::Result<Vec<irontide_core::FilePriority>> {
2959        let (tx, rx) = oneshot::channel();
2960        self.cmd_tx
2961            .send(SessionCommand::FilePriorities {
2962                info_hash,
2963                reply: tx,
2964            })
2965            .await
2966            .map_err(|_| crate::Error::Shutdown)?;
2967        rx.await.map_err(|_| crate::Error::Shutdown)?
2968    }
2969
2970    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
2971    ///
2972    /// # Errors
2973    ///
2974    /// Returns an error if the data cannot be parsed or I/O fails.
2975    pub async fn set_download_limit(
2976        &self,
2977        info_hash: Id20,
2978        bytes_per_sec: u64,
2979    ) -> crate::Result<()> {
2980        let (tx, rx) = oneshot::channel();
2981        self.cmd_tx
2982            .send(SessionCommand::SetDownloadLimit {
2983                info_hash,
2984                bytes_per_sec,
2985                reply: tx,
2986            })
2987            .await
2988            .map_err(|_| crate::Error::Shutdown)?;
2989        rx.await.map_err(|_| crate::Error::Shutdown)?
2990    }
2991
2992    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
2993    ///
2994    /// # Errors
2995    ///
2996    /// Returns an error if the data cannot be parsed or I/O fails.
2997    pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
2998        let (tx, rx) = oneshot::channel();
2999        self.cmd_tx
3000            .send(SessionCommand::SetUploadLimit {
3001                info_hash,
3002                bytes_per_sec,
3003                reply: tx,
3004            })
3005            .await
3006            .map_err(|_| crate::Error::Shutdown)?;
3007        rx.await.map_err(|_| crate::Error::Shutdown)?
3008    }
3009
3010    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
3011    ///
3012    /// # Errors
3013    ///
3014    /// Returns an error if the data cannot be parsed or I/O fails.
3015    pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3016        let (tx, rx) = oneshot::channel();
3017        self.cmd_tx
3018            .send(SessionCommand::DownloadLimit {
3019                info_hash,
3020                reply: tx,
3021            })
3022            .await
3023            .map_err(|_| crate::Error::Shutdown)?;
3024        rx.await.map_err(|_| crate::Error::Shutdown)?
3025    }
3026
3027    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
3028    ///
3029    /// # Errors
3030    ///
3031    /// Returns an error if the data cannot be parsed or I/O fails.
3032    pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3033        let (tx, rx) = oneshot::channel();
3034        self.cmd_tx
3035            .send(SessionCommand::UploadLimit {
3036                info_hash,
3037                reply: tx,
3038            })
3039            .await
3040            .map_err(|_| crate::Error::Shutdown)?;
3041        rx.await.map_err(|_| crate::Error::Shutdown)?
3042    }
3043
3044    /// Enable or disable sequential (in-order) piece downloading for a torrent.
3045    ///
3046    /// # Errors
3047    ///
3048    /// Returns an error if the data cannot be parsed or I/O fails.
3049    pub async fn set_sequential_download(
3050        &self,
3051        info_hash: Id20,
3052        enabled: bool,
3053    ) -> crate::Result<()> {
3054        let (tx, rx) = oneshot::channel();
3055        self.cmd_tx
3056            .send(SessionCommand::SetSequentialDownload {
3057                info_hash,
3058                enabled,
3059                reply: tx,
3060            })
3061            .await
3062            .map_err(|_| crate::Error::Shutdown)?;
3063        rx.await.map_err(|_| crate::Error::Shutdown)?
3064    }
3065
3066    /// Query whether sequential downloading is enabled for a torrent.
3067    ///
3068    /// # Errors
3069    ///
3070    /// Returns an error if the data cannot be parsed or I/O fails.
3071    pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3072        let (tx, rx) = oneshot::channel();
3073        self.cmd_tx
3074            .send(SessionCommand::IsSequentialDownload {
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    /// Enable or disable BEP 16 super seeding mode for a torrent.
3084    ///
3085    /// # Errors
3086    ///
3087    /// Returns an error if the session is shut down.
3088    pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3089        let (tx, rx) = oneshot::channel();
3090        self.cmd_tx
3091            .send(SessionCommand::SetSuperSeeding {
3092                info_hash,
3093                enabled,
3094                reply: tx,
3095            })
3096            .await
3097            .map_err(|_| crate::Error::Shutdown)?;
3098        rx.await.map_err(|_| crate::Error::Shutdown)?
3099    }
3100
3101    /// Query whether BEP 16 super seeding mode is enabled for a torrent.
3102    ///
3103    /// # Errors
3104    ///
3105    /// Returns an error if the session is shut down.
3106    pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3107        let (tx, rx) = oneshot::channel();
3108        self.cmd_tx
3109            .send(SessionCommand::IsSuperSeeding {
3110                info_hash,
3111                reply: tx,
3112            })
3113            .await
3114            .map_err(|_| crate::Error::Shutdown)?;
3115        rx.await.map_err(|_| crate::Error::Shutdown)?
3116    }
3117
3118    /// Enable or disable user-requested seed-only mode for a torrent (M159).
3119    ///
3120    /// When `enabled` is `true`, the engine stops scheduling new block requests
3121    /// and cancels all in-flight requests for the torrent, but continues to
3122    /// serve uploads to interested peers. This is distinct from "naturally
3123    /// seeding" (all pieces downloaded): it represents an explicit user toggle
3124    /// layered on top of the download state.
3125    ///
3126    /// Toggling back to `false` resumes normal piece scheduling.
3127    ///
3128    /// # Errors
3129    ///
3130    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` is not registered
3131    /// in the session, or [`crate::Error::Shutdown`] if the session actor has
3132    /// terminated.
3133    pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3134        let (tx, rx) = oneshot::channel();
3135        self.cmd_tx
3136            .send(SessionCommand::SetSeedMode {
3137                info_hash,
3138                enabled,
3139                reply: tx,
3140            })
3141            .await
3142            .map_err(|_| crate::Error::Shutdown)?;
3143        rx.await.map_err(|_| crate::Error::Shutdown)?
3144    }
3145
3146    /// Add a new tracker URL to a torrent.
3147    ///
3148    /// The URL is validated and deduplicated by the tracker manager.
3149    ///
3150    /// # Errors
3151    ///
3152    /// Returns an error if the session is shut down.
3153    pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3154        let (tx, rx) = oneshot::channel();
3155        self.cmd_tx
3156            .send(SessionCommand::AddTracker {
3157                info_hash,
3158                url,
3159                reply: tx,
3160            })
3161            .await
3162            .map_err(|_| crate::Error::Shutdown)?;
3163        rx.await.map_err(|_| crate::Error::Shutdown)?
3164    }
3165
3166    /// Replace all tracker URLs for a torrent.
3167    ///
3168    /// # Errors
3169    ///
3170    /// Returns an error if the session is shut down.
3171    pub async fn replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3172        let (tx, rx) = oneshot::channel();
3173        self.cmd_tx
3174            .send(SessionCommand::ReplaceTrackers {
3175                info_hash,
3176                urls,
3177                reply: tx,
3178            })
3179            .await
3180            .map_err(|_| crate::Error::Shutdown)?;
3181        rx.await.map_err(|_| crate::Error::Shutdown)?
3182    }
3183
3184    /// Trigger a full piece verification (force recheck) for a torrent.
3185    ///
3186    /// Clears all piece completion data, re-verifies every piece, and
3187    /// transitions to `Seeding` or `Downloading` depending on the result.
3188    /// Returns after the recheck is complete.
3189    ///
3190    /// # Errors
3191    ///
3192    /// Returns an error if the session is shut down.
3193    pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3194        let (tx, rx) = oneshot::channel();
3195        self.cmd_tx
3196            .send(SessionCommand::ForceRecheck {
3197                info_hash,
3198                reply: tx,
3199            })
3200            .await
3201            .map_err(|_| crate::Error::Shutdown)?;
3202        rx.await.map_err(|_| crate::Error::Shutdown)?
3203    }
3204
3205    /// Rename a file within a torrent on disk.
3206    ///
3207    /// Changes the filename of the specified file (by index) to `new_name`.
3208    /// The file stays in the same directory; only the filename component changes.
3209    /// Fires a `FileRenamed` alert on success.
3210    ///
3211    /// # Errors
3212    ///
3213    /// Returns an error if the session is shut down.
3214    pub async fn rename_file(
3215        &self,
3216        info_hash: Id20,
3217        file_index: usize,
3218        new_name: String,
3219    ) -> crate::Result<()> {
3220        let (tx, rx) = oneshot::channel();
3221        self.cmd_tx
3222            .send(SessionCommand::RenameFile {
3223                info_hash,
3224                file_index,
3225                new_name,
3226                reply: tx,
3227            })
3228            .await
3229            .map_err(|_| crate::Error::Shutdown)?;
3230        rx.await.map_err(|_| crate::Error::Shutdown)?
3231    }
3232
3233    /// Set the per-torrent maximum number of connections (0 = use global default).
3234    ///
3235    /// # Errors
3236    ///
3237    /// Returns an error if the connection or binding fails.
3238    pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3239        let (tx, rx) = oneshot::channel();
3240        self.cmd_tx
3241            .send(SessionCommand::SetMaxConnections {
3242                info_hash,
3243                limit,
3244                reply: tx,
3245            })
3246            .await
3247            .map_err(|_| crate::Error::Shutdown)?;
3248        rx.await.map_err(|_| crate::Error::Shutdown)?
3249    }
3250
3251    /// Get the current per-torrent maximum connection limit (0 = use global default).
3252    ///
3253    /// # Errors
3254    ///
3255    /// Returns an error if the connection or binding fails.
3256    pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3257        let (tx, rx) = oneshot::channel();
3258        self.cmd_tx
3259            .send(SessionCommand::MaxConnections {
3260                info_hash,
3261                reply: tx,
3262            })
3263            .await
3264            .map_err(|_| crate::Error::Shutdown)?;
3265        rx.await.map_err(|_| crate::Error::Shutdown)?
3266    }
3267
3268    /// Set the per-torrent maximum number of upload slots (unchoke slots).
3269    ///
3270    /// # Errors
3271    ///
3272    /// Returns an error if the data cannot be parsed or I/O fails.
3273    pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3274        let (tx, rx) = oneshot::channel();
3275        self.cmd_tx
3276            .send(SessionCommand::SetMaxUploads {
3277                info_hash,
3278                limit,
3279                reply: tx,
3280            })
3281            .await
3282            .map_err(|_| crate::Error::Shutdown)?;
3283        rx.await.map_err(|_| crate::Error::Shutdown)?
3284    }
3285
3286    /// Get the current per-torrent maximum upload slots (unchoke slots).
3287    ///
3288    /// # Errors
3289    ///
3290    /// Returns an error if the data cannot be parsed or I/O fails.
3291    pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3292        let (tx, rx) = oneshot::channel();
3293        self.cmd_tx
3294            .send(SessionCommand::MaxUploads {
3295                info_hash,
3296                reply: tx,
3297            })
3298            .await
3299            .map_err(|_| crate::Error::Shutdown)?;
3300        rx.await.map_err(|_| crate::Error::Shutdown)?
3301    }
3302
3303    /// Get per-peer details for all connected peers of a torrent.
3304    ///
3305    /// # Errors
3306    ///
3307    /// Returns an error if the session is shut down.
3308    pub async fn get_peer_info(
3309        &self,
3310        info_hash: Id20,
3311    ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3312        let (tx, rx) = oneshot::channel();
3313        self.cmd_tx
3314            .send(SessionCommand::GetPeerInfo {
3315                info_hash,
3316                reply: tx,
3317            })
3318            .await
3319            .map_err(|_| crate::Error::Shutdown)?;
3320        rx.await.map_err(|_| crate::Error::Shutdown)?
3321    }
3322
3323    /// Get in-flight piece download status for a torrent (the download queue).
3324    ///
3325    /// # Errors
3326    ///
3327    /// Returns an error if the data cannot be parsed or I/O fails.
3328    pub async fn get_download_queue(
3329        &self,
3330        info_hash: Id20,
3331    ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3332        let (tx, rx) = oneshot::channel();
3333        self.cmd_tx
3334            .send(SessionCommand::GetDownloadQueue {
3335                info_hash,
3336                reply: tx,
3337            })
3338            .await
3339            .map_err(|_| crate::Error::Shutdown)?;
3340        rx.await.map_err(|_| crate::Error::Shutdown)?
3341    }
3342
3343    /// Check whether a specific piece has been downloaded for a torrent.
3344    ///
3345    /// # Errors
3346    ///
3347    /// Returns an error if the session is shut down.
3348    pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3349        let (tx, rx) = oneshot::channel();
3350        self.cmd_tx
3351            .send(SessionCommand::HavePiece {
3352                info_hash,
3353                index,
3354                reply: tx,
3355            })
3356            .await
3357            .map_err(|_| crate::Error::Shutdown)?;
3358        rx.await.map_err(|_| crate::Error::Shutdown)?
3359    }
3360
3361    /// Get per-piece availability counts from connected peers for a torrent.
3362    ///
3363    /// # Errors
3364    ///
3365    /// Returns an error if the session is shut down.
3366    pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3367        let (tx, rx) = oneshot::channel();
3368        self.cmd_tx
3369            .send(SessionCommand::PieceAvailability {
3370                info_hash,
3371                reply: tx,
3372            })
3373            .await
3374            .map_err(|_| crate::Error::Shutdown)?;
3375        rx.await.map_err(|_| crate::Error::Shutdown)?
3376    }
3377
3378    /// Get per-file bytes-downloaded progress for a torrent.
3379    ///
3380    /// # Errors
3381    ///
3382    /// Returns an error if the session is shut down.
3383    pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3384        let (tx, rx) = oneshot::channel();
3385        self.cmd_tx
3386            .send(SessionCommand::FileProgress {
3387                info_hash,
3388                reply: tx,
3389            })
3390            .await
3391            .map_err(|_| crate::Error::Shutdown)?;
3392        rx.await.map_err(|_| crate::Error::Shutdown)?
3393    }
3394
3395    /// Get the torrent's identity hashes (v1 and/or v2).
3396    ///
3397    /// # Errors
3398    ///
3399    /// Returns an error if the session is shut down.
3400    pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3401        let (tx, rx) = oneshot::channel();
3402        self.cmd_tx
3403            .send(SessionCommand::InfoHashesQuery {
3404                info_hash,
3405                reply: tx,
3406            })
3407            .await
3408            .map_err(|_| crate::Error::Shutdown)?;
3409        rx.await.map_err(|_| crate::Error::Shutdown)?
3410    }
3411
3412    /// Get the full v1 metainfo for a torrent.
3413    ///
3414    /// Returns `None` for magnet links before metadata has been received.
3415    ///
3416    /// # Errors
3417    ///
3418    /// Returns an error if the session is shut down.
3419    pub async fn torrent_file(
3420        &self,
3421        info_hash: Id20,
3422    ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3423        let (tx, rx) = oneshot::channel();
3424        self.cmd_tx
3425            .send(SessionCommand::TorrentFile {
3426                info_hash,
3427                reply: tx,
3428            })
3429            .await
3430            .map_err(|_| crate::Error::Shutdown)?;
3431        rx.await.map_err(|_| crate::Error::Shutdown)?
3432    }
3433
3434    /// Get the full v2 metainfo for a torrent.
3435    ///
3436    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
3437    /// links before metadata has been received.
3438    ///
3439    /// # Errors
3440    ///
3441    /// Returns an error if the session is shut down.
3442    pub async fn torrent_file_v2(
3443        &self,
3444        info_hash: Id20,
3445    ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3446        let (tx, rx) = oneshot::channel();
3447        self.cmd_tx
3448            .send(SessionCommand::TorrentFileV2 {
3449                info_hash,
3450                reply: tx,
3451            })
3452            .await
3453            .map_err(|_| crate::Error::Shutdown)?;
3454        rx.await.map_err(|_| crate::Error::Shutdown)?
3455    }
3456
3457    /// **TEST-ONLY.** Synchronously inject an info-dict into a torrent's
3458    /// `MetadataDownloader` queue. Returns only after the actor has processed
3459    /// the message. Reuses the existing M147 internal handler at
3460    /// `torrent.rs:3665` via a separate test-only `TorrentCommand` variant
3461    /// (the production [`TorrentHandle::send_pre_resolved_metadata`] is
3462    /// fire-and-forget and stays unchanged).
3463    ///
3464    /// Introduced in v0.173.2 as a cross-crate test escape hatch for the
3465    /// `irontide-api` A9 HTTP integration test — it lets tests exercise the
3466    /// post-metadata HTTP surface without spinning up real peers.
3467    ///
3468    /// # Errors
3469    /// - [`crate::Error::TorrentNotFound`] if `info_hash` is not registered.
3470    /// - [`crate::Error::Shutdown`] if the session or torrent command
3471    ///   channel is closed.
3472    #[cfg(feature = "test-util")]
3473    pub async fn debug_inject_metadata(
3474        &self,
3475        info_hash: Id20,
3476        info_bytes: Vec<u8>,
3477    ) -> crate::Result<()> {
3478        let (tx, rx) = oneshot::channel();
3479        self.cmd_tx
3480            .send(SessionCommand::TestInjectMetadata {
3481                info_hash,
3482                info_bytes,
3483                reply: tx,
3484            })
3485            .await
3486            .map_err(|_| crate::Error::Shutdown)?;
3487        rx.await.map_err(|_| crate::Error::Shutdown)?
3488    }
3489
3490    /// Force an immediate DHT announce for a torrent.
3491    ///
3492    /// # Errors
3493    ///
3494    /// Returns an error if the session is shut down.
3495    pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3496        let (tx, rx) = oneshot::channel();
3497        self.cmd_tx
3498            .send(SessionCommand::ForceDhtAnnounce {
3499                info_hash,
3500                reply: tx,
3501            })
3502            .await
3503            .map_err(|_| crate::Error::Shutdown)?;
3504        rx.await.map_err(|_| crate::Error::Shutdown)?
3505    }
3506
3507    /// Force an immediate LSD (Local Service Discovery) announce for a torrent.
3508    ///
3509    /// LSD is a session-level component — this does not go through the torrent actor.
3510    ///
3511    /// # Errors
3512    ///
3513    /// Returns an error if the session is shut down.
3514    pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3515        let (tx, rx) = oneshot::channel();
3516        self.cmd_tx
3517            .send(SessionCommand::ForceLsdAnnounce {
3518                info_hash,
3519                reply: tx,
3520            })
3521            .await
3522            .map_err(|_| crate::Error::Shutdown)?;
3523        rx.await.map_err(|_| crate::Error::Shutdown)?
3524    }
3525
3526    /// Read all data for a specific piece from disk.
3527    ///
3528    /// # Errors
3529    ///
3530    /// Returns an error if the data cannot be parsed or I/O fails.
3531    pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3532        let (tx, rx) = oneshot::channel();
3533        self.cmd_tx
3534            .send(SessionCommand::ReadPiece {
3535                info_hash,
3536                index,
3537                reply: tx,
3538            })
3539            .await
3540            .map_err(|_| crate::Error::Shutdown)?;
3541        rx.await.map_err(|_| crate::Error::Shutdown)?
3542    }
3543
3544    /// Flush the disk write cache for a torrent.
3545    ///
3546    /// # Errors
3547    ///
3548    /// Returns an error if the session is shut down.
3549    pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3550        let (tx, rx) = oneshot::channel();
3551        self.cmd_tx
3552            .send(SessionCommand::FlushCache {
3553                info_hash,
3554                reply: tx,
3555            })
3556            .await
3557            .map_err(|_| crate::Error::Shutdown)?;
3558        rx.await.map_err(|_| crate::Error::Shutdown)?
3559    }
3560
3561    /// Check if a torrent exists in the session and its handle is still valid.
3562    pub async fn is_valid(&self, info_hash: Id20) -> bool {
3563        let (tx, rx) = oneshot::channel();
3564        if self
3565            .cmd_tx
3566            .send(SessionCommand::IsValid {
3567                info_hash,
3568                reply: tx,
3569            })
3570            .await
3571            .is_err()
3572        {
3573            return false;
3574        }
3575        rx.await.unwrap_or(false)
3576    }
3577
3578    /// Clear the error state on a torrent, resuming it if it was paused due to error.
3579    ///
3580    /// # Errors
3581    ///
3582    /// Returns an error if the session is shut down.
3583    pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3584        let (tx, rx) = oneshot::channel();
3585        self.cmd_tx
3586            .send(SessionCommand::ClearError {
3587                info_hash,
3588                reply: tx,
3589            })
3590            .await
3591            .map_err(|_| crate::Error::Shutdown)?;
3592        rx.await.map_err(|_| crate::Error::Shutdown)?
3593    }
3594
3595    /// Get per-file open/mode status for a torrent.
3596    ///
3597    /// # Errors
3598    ///
3599    /// Returns an error if the session is shut down.
3600    pub async fn file_status(
3601        &self,
3602        info_hash: Id20,
3603    ) -> crate::Result<Vec<crate::types::FileStatus>> {
3604        let (tx, rx) = oneshot::channel();
3605        self.cmd_tx
3606            .send(SessionCommand::FileStatus {
3607                info_hash,
3608                reply: tx,
3609            })
3610            .await
3611            .map_err(|_| crate::Error::Shutdown)?;
3612        rx.await.map_err(|_| crate::Error::Shutdown)?
3613    }
3614
3615    /// Read the current torrent flags as a [`crate::types::TorrentFlags`] bitflag set.
3616    ///
3617    /// # Errors
3618    ///
3619    /// Returns an error if the session is shut down.
3620    pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3621        let (tx, rx) = oneshot::channel();
3622        self.cmd_tx
3623            .send(SessionCommand::Flags {
3624                info_hash,
3625                reply: tx,
3626            })
3627            .await
3628            .map_err(|_| crate::Error::Shutdown)?;
3629        rx.await.map_err(|_| crate::Error::Shutdown)?
3630    }
3631
3632    /// Set (enable) the specified torrent flags.
3633    ///
3634    /// # Errors
3635    ///
3636    /// Returns an error if the session is shut down.
3637    pub async fn set_flags(
3638        &self,
3639        info_hash: Id20,
3640        flags: crate::types::TorrentFlags,
3641    ) -> crate::Result<()> {
3642        let (tx, rx) = oneshot::channel();
3643        self.cmd_tx
3644            .send(SessionCommand::SetFlags {
3645                info_hash,
3646                flags,
3647                reply: tx,
3648            })
3649            .await
3650            .map_err(|_| crate::Error::Shutdown)?;
3651        rx.await.map_err(|_| crate::Error::Shutdown)?
3652    }
3653
3654    /// Unset (disable) the specified torrent flags.
3655    ///
3656    /// # Errors
3657    ///
3658    /// Returns an error if the session is shut down.
3659    pub async fn unset_flags(
3660        &self,
3661        info_hash: Id20,
3662        flags: crate::types::TorrentFlags,
3663    ) -> crate::Result<()> {
3664        let (tx, rx) = oneshot::channel();
3665        self.cmd_tx
3666            .send(SessionCommand::UnsetFlags {
3667                info_hash,
3668                flags,
3669                reply: tx,
3670            })
3671            .await
3672            .map_err(|_| crate::Error::Shutdown)?;
3673        rx.await.map_err(|_| crate::Error::Shutdown)?
3674    }
3675
3676    /// Immediately initiate a peer connection for a torrent.
3677    ///
3678    /// # Errors
3679    ///
3680    /// Returns an error if the connection or binding fails.
3681    pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3682        let (tx, rx) = oneshot::channel();
3683        self.cmd_tx
3684            .send(SessionCommand::ConnectPeer {
3685                info_hash,
3686                addr,
3687                reply: tx,
3688            })
3689            .await
3690            .map_err(|_| crate::Error::Shutdown)?;
3691        rx.await.map_err(|_| crate::Error::Shutdown)?
3692    }
3693
3694    /// Store an immutable item in the DHT (BEP 44).
3695    ///
3696    /// Returns the SHA-1 target hash of the stored value.
3697    ///
3698    /// # Errors
3699    ///
3700    /// Returns an error if the session is shut down.
3701    pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3702        let (tx, rx) = oneshot::channel();
3703        self.cmd_tx
3704            .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3705            .await
3706            .map_err(|_| crate::Error::Shutdown)?;
3707        rx.await.map_err(|_| crate::Error::Shutdown)?
3708    }
3709
3710    /// Retrieve an immutable item from the DHT (BEP 44).
3711    ///
3712    /// Returns `Some(value)` if found, `None` otherwise.
3713    ///
3714    /// # Errors
3715    ///
3716    /// Returns an error if the session is shut down.
3717    pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3718        let (tx, rx) = oneshot::channel();
3719        self.cmd_tx
3720            .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3721            .await
3722            .map_err(|_| crate::Error::Shutdown)?;
3723        rx.await.map_err(|_| crate::Error::Shutdown)?
3724    }
3725
3726    /// Store a mutable item in the DHT (BEP 44).
3727    ///
3728    /// `keypair_bytes` is a 32-byte Ed25519 seed. Returns the target hash.
3729    ///
3730    /// # Errors
3731    ///
3732    /// Returns an error if the session is shut down.
3733    pub async fn dht_put_mutable(
3734        &self,
3735        keypair_bytes: [u8; 32],
3736        value: Vec<u8>,
3737        seq: i64,
3738        salt: Vec<u8>,
3739    ) -> crate::Result<Id20> {
3740        let (tx, rx) = oneshot::channel();
3741        self.cmd_tx
3742            .send(SessionCommand::DhtPutMutable {
3743                keypair_bytes,
3744                value,
3745                seq,
3746                salt,
3747                reply: tx,
3748            })
3749            .await
3750            .map_err(|_| crate::Error::Shutdown)?;
3751        rx.await.map_err(|_| crate::Error::Shutdown)?
3752    }
3753
3754    /// Retrieve a mutable item from the DHT (BEP 44).
3755    ///
3756    /// Returns `Some((value, seq))` if found, `None` otherwise.
3757    ///
3758    /// # Errors
3759    ///
3760    /// Returns an error if the session is shut down.
3761    pub async fn dht_get_mutable(
3762        &self,
3763        public_key: [u8; 32],
3764        salt: Vec<u8>,
3765    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3766        let (tx, rx) = oneshot::channel();
3767        self.cmd_tx
3768            .send(SessionCommand::DhtGetMutable {
3769                public_key,
3770                salt,
3771                reply: tx,
3772            })
3773            .await
3774            .map_err(|_| crate::Error::Shutdown)?;
3775        rx.await.map_err(|_| crate::Error::Shutdown)?
3776    }
3777
3778    // ── M121: Convenience API for future HTTP endpoints ──
3779
3780    /// List all torrents as lightweight summaries.
3781    ///
3782    /// M245 A1: reads the lock-free published [`SessionSnapshot`] — a single
3783    /// `Arc` load, no per-torrent command round-trips (the pre-M245 body issued
3784    /// one `stats()` round-trip per torrent on the actor's recv loop).
3785    /// Membership is read-after-write consistent (the actor patches it eagerly
3786    /// on add/remove); sampled stats are eventually-consistent to one stats
3787    /// tick. Summaries are ascending by info-hash.
3788    ///
3789    /// # Errors
3790    ///
3791    /// Never errors — the snapshot read is infallible. The `Result` is retained
3792    /// for facade API stability (M245 D5).
3793    #[allow(
3794        clippy::unused_async,
3795        reason = "async + Result signature kept for facade API stability (M245 D5); the snapshot read is synchronous and infallible"
3796    )]
3797    pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
3798        Ok(self.snapshot.load().summaries())
3799    }
3800
3801    /// Add a torrent from a magnet URI string.
3802    ///
3803    /// Parses the URI, extracts info hashes, and adds the magnet to the session.
3804    /// Returns the info hashes (v1 and/or v2) for the added torrent.
3805    ///
3806    /// # Errors
3807    ///
3808    /// Returns an error if the torrent cannot be added or the session is shut down.
3809    pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
3810        let magnet = irontide_core::Magnet::parse(uri)?;
3811        let info_hashes = magnet.info_hashes.clone();
3812        self.add_magnet(magnet).await?;
3813        Ok(info_hashes)
3814    }
3815
3816    /// Add a torrent from raw .torrent file bytes.
3817    ///
3818    /// Auto-detects v1, v2, or hybrid format. Returns the info hashes for the
3819    /// added torrent.
3820    ///
3821    /// # Errors
3822    ///
3823    /// Returns an error if the torrent cannot be added or the session is shut down.
3824    pub async fn add_torrent_bytes(
3825        &self,
3826        bytes: &[u8],
3827    ) -> crate::Result<irontide_core::InfoHashes> {
3828        let meta = irontide_core::torrent_from_bytes_any(bytes)?;
3829        let info_hashes = meta.info_hashes();
3830        self.add_torrent_with_meta(meta, None).await?;
3831        Ok(info_hashes)
3832    }
3833}
3834
3835// ---------------------------------------------------------------------------
3836// SessionSnapshot — M245 A1 published lock-free read-model
3837// ---------------------------------------------------------------------------
3838
3839/// Lock-free read-model published by the [`SessionActor`] (M245 A1).
3840///
3841/// **Consistency contract (hybrid):**
3842/// - *Membership* (which torrents exist) is **read-after-write consistent** — the
3843///   actor patches this map synchronously on add/remove, so a torrent is visible
3844///   the instant its add commits.
3845/// - *Sampled stats* (rates, progress, peer counts) are **eventually-consistent to
3846///   one stats tick** — the same skew any sampled counter already carries.
3847///
3848/// Held in an [`arc_swap::ArcSwap`] so read-only callers clone an `Arc` snapshot
3849/// without touching the mutating command mailbox.
3850#[derive(Debug, Default, Clone)]
3851pub struct SessionSnapshot {
3852    by_id: std::collections::BTreeMap<Id20, TorrentSummary>,
3853}
3854
3855impl SessionSnapshot {
3856    /// All torrent summaries, ascending by info-hash. Owned clone — callers that
3857    /// only need the count or a scan can use [`SessionSnapshot::as_map`] instead.
3858    #[must_use]
3859    pub fn summaries(&self) -> Vec<TorrentSummary> {
3860        self.by_id.values().cloned().collect()
3861    }
3862
3863    /// Internal: the keyed map, for the actor's O(log n) membership patches
3864    /// (eager add/remove) and the per-tick stats refresh carry-forward.
3865    pub(crate) fn as_map(&self) -> &std::collections::BTreeMap<Id20, TorrentSummary> {
3866        &self.by_id
3867    }
3868
3869    /// Internal: build a snapshot from a patched map (actor-side only).
3870    pub(crate) fn from_map(by_id: std::collections::BTreeMap<Id20, TorrentSummary>) -> Self {
3871        Self { by_id }
3872    }
3873}
3874
3875// ---------------------------------------------------------------------------
3876// SessionActor — internal single-owner event loop
3877// ---------------------------------------------------------------------------
3878
3879struct SessionActor {
3880    settings: Settings,
3881    /// M223 — self-loopback sender, so the spawned `handle_add_torrent`
3882    /// prep tasks can route their `CommitAddTorrent` result back through
3883    /// the same recv loop that owns the mutating session state.
3884    /// Cloned from the same sender the `SessionHandle` holds; carries
3885    /// the M221.1a timing wrapper so the commit hop is observable in
3886    /// the `queue_wait_ms` / `handler_ms` telemetry.
3887    commit_tx: SessionCmdSender,
3888    torrents: HashMap<Id20, TorrentEntry>,
3889    /// M245 A1 — the published read-model the [`SessionHandle`] reads from.
3890    /// Same `ArcSwap` the handle holds (cloned at construction). The actor is
3891    /// the SOLE writer: an eager membership patch on every add/remove (so
3892    /// reads are read-after-write consistent) plus a sampled-stats refresh
3893    /// derived from the per-tick fan-out in [`make_session_stats`](Self::make_session_stats).
3894    snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
3895    dht_v4: Option<DhtHandle>,
3896    dht_v6: Option<DhtHandle>,
3897    /// M173 Lane B (B6): broadcast surface for runtime `DhtHandle`
3898    /// replacement. `TorrentActor` consumers borrow a `DhtReceiver`
3899    /// instead of cloning `DhtHandle`, so a session-side DHT restart
3900    /// (B11) propagates to every torrent on the next `borrow()`.
3901    /// Initialised once at session start with the same value as the
3902    /// `dht_v4`/`dht_v6` clones; never written until B11.
3903    dht_v4_broadcast: irontide_dht::DhtBroadcast,
3904    dht_v6_broadcast: irontide_dht::DhtBroadcast,
3905    lsd: Option<crate::lsd::LsdHandle>,
3906    lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
3907    cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
3908    alert_tx: broadcast::Sender<Alert>,
3909    alert_mask: Arc<AtomicU32>,
3910    global_upload_bucket: SharedBucket,
3911    global_download_bucket: SharedBucket,
3912    utp_socket: Option<irontide_utp::UtpSocket>,
3913    utp_socket_v6: Option<irontide_utp::UtpSocket>,
3914    nat: Option<irontide_nat::NatHandle>,
3915    nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
3916    ban_manager: SharedBanManager,
3917    ip_filter: SharedIpFilter,
3918    disk_manager: crate::disk::DiskManagerHandle,
3919    #[allow(dead_code)]
3920    disk_actor_handle: tokio::task::JoinHandle<()>,
3921    /// External IP discovered via NAT traversal or configured manually (BEP 40).
3922    external_ip: Option<std::net::IpAddr>,
3923    /// BEP 42: External IP consensus from DHT v4 KRPC responses.
3924    dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3925    /// BEP 42: External IP consensus from DHT v6 KRPC responses.
3926    dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3927    /// Registered extension plugins, shared with all `TorrentActors`.
3928    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
3929    /// I2P SAM session (if enabled).
3930    sam_session: Option<Arc<crate::i2p::SamSession>>,
3931    /// SSL manager for SSL torrent certificate handling (M42).
3932    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
3933    /// SSL/TLS TCP listener (separate port from the main listener) (M42).
3934    ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
3935    /// Channel receiving pre-validated inbound connections from the `ListenerTask` (M114).
3936    validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
3937    /// Registry of active info hashes shared with the `ListenerTask` (M114).
3938    /// INVARIANT: Must be updated whenever torrents are added/removed.
3939    /// If a new torrent-add path is added without updating this registry,
3940    /// inbound connections for that torrent will be silently rejected.
3941    info_hash_registry: Arc<DashMap<Id20, ()>>,
3942    /// Handle to keep the listener task alive; dropped on session shutdown
3943    /// (M114). M173 Lane B (B2) replaced the bare `JoinHandle` with
3944    /// `ListenerHandle` so the listen-port rebind path (B4) can call
3945    /// `shutdown_with_timeout` for a clean port swap.
3946    #[allow(dead_code)] // used by Drop sequence + B4 listen-port rebind
3947    _listener_task: crate::listener::ListenerHandle,
3948    /// M224 D3: global TCP-inbound connection cap, shared with the
3949    /// `ListenerTask`. `-1` = unlimited. Updated atomically from
3950    /// `handle_apply_settings` so the listener sees the new value on the
3951    /// next accept without restarting the task.
3952    max_connections_global: Arc<std::sync::atomic::AtomicI32>,
3953    /// M224 D3: live TCP-inbound connection count, shared with the
3954    /// `ListenerTask`. Incremented at TCP accept; decremented when the
3955    /// `LiveConnectionGuard` attached to the accepted connection drops.
3956    /// Kept here so future milestones (M225 outbound, observability) can
3957    /// read the live count without touching the listener.
3958    #[allow(dead_code)] // read via the M224 G1 integration test + future M225 observability.
3959    live_connections: Arc<std::sync::atomic::AtomicUsize>,
3960    /// Shared atomic session counters (M50).
3961    counters: Arc<crate::stats::SessionCounters>,
3962    /// Network transport factory for TCP operations (M51).
3963    factory: Arc<crate::transport::NetworkFactory>,
3964    /// Shared hash pool for parallel piece verification (M96).
3965    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
3966    /// M170: qBt-compat category registry, shared with CRUD call paths.
3967    category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
3968    /// M171: qBt-compat tag registry, shared with CRUD call paths.
3969    tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
3970    /// M170: info hashes currently in the `remove_torrent_with_files`
3971    /// grace period. Guards against fast delete-then-re-add sequences
3972    /// that would otherwise race the file-deletion walker.
3973    deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
3974    /// M173 Lane B: in-flight `apply_settings` guard. Concurrent
3975    /// `setPreferences` calls hit `ApplyError::ConcurrentReconfig`
3976    /// rather than interleaving rate-limiter / listener-rebind /
3977    /// DHT-restart phases. Wired by B11.
3978    #[allow(dead_code)] // wired by B11 — concurrent setPreferences guard
3979    reconfig_in_flight: crate::apply::ReconfigInFlight,
3980    self_alert_rx: broadcast::Receiver<Alert>,
3981    resume_save_notify: Arc<tokio::sync::Notify>,
3982    /// M241 L1 / F4: single-flight guard serializing every off-loop resume save.
3983    /// `atomic_write` uses a fixed `<hash>.resume.tmp` temp path, so two
3984    /// concurrent saves of the same torrent would race that temp and corrupt the
3985    /// file. The periodic tick uses `try_lock_owned` (skip when busy); the
3986    /// `SaveResumeState` RPC + the inline shutdown save wait on it.
3987    resume_save_lock: Arc<tokio::sync::Mutex<()>>,
3988    /// M226 Step 5: live settings broadcast to the engine-side OS
3989    /// notification dispatcher spawned in `start_full`. Updated from
3990    /// `handle_apply_settings` so `notify_on_complete` /
3991    /// `notify_on_error` toggles take effect on the very next alert
3992    /// without restarting the dispatcher task.
3993    notification_settings_tx: tokio::sync::watch::Sender<Settings>,
3994    /// M226 Step 5: held alive only for its `Drop` — when the
3995    /// `SessionActor` ends and this field drops, the matching
3996    /// `oneshot::Receiver` in the dispatcher resolves and the
3997    /// dispatcher loop exits cleanly. Belt-and-suspenders with the
3998    /// `alert_tx` broadcast closure (the dispatcher also exits on
3999    /// `RecvError::Closed`), since `alert_tx` is held by every
4000    /// outstanding `SessionHandle::subscribe()` consumer and may
4001    /// outlive the actor.
4002    #[allow(dead_code)]
4003    notification_shutdown_tx: oneshot::Sender<()>,
4004    /// M226 Step 6: triggers the watched-folder dispatcher to drop
4005    /// its current debouncer (releasing inotify FDs) and rebuild
4006    /// against the new path. Pinged by `handle_apply_settings` when
4007    /// the `SettingsDelta` carries a change on `watched_folder` or
4008    /// `delete_torrent_after_add`. Separate from the Settings watch
4009    /// channel because rate-limit / DHT / mask changes shouldn't
4010    /// cause inotify churn.
4011    watched_folder_changed: Arc<tokio::sync::Notify>,
4012    /// M226 Step 6: same Drop-as-shutdown pattern as
4013    /// [`Self::notification_shutdown_tx`].
4014    #[allow(dead_code)]
4015    watched_folder_shutdown_tx: oneshot::Sender<()>,
4016}
4017
4018impl SessionActor {
4019    /// v0.173.1: shared helper for reader sites that used to read
4020    /// `TorrentEntry.meta` (a stale cache that was silently `None` forever
4021    /// for magnet-added torrents). Always queries the `TorrentActor` — the
4022    /// single source of truth for torrent metadata post-v0.173.1.
4023    ///
4024    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` isn't in the
4025    /// registry, [`crate::Error::MetadataNotReady`] if the actor has not yet
4026    /// assembled metadata (magnet pre-resolution), or propagates
4027    /// [`crate::Error::Shutdown`] if the actor has already exited.
4028    async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4029        let entry = self
4030            .torrents
4031            .get(&info_hash)
4032            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4033        entry
4034            .handle
4035            .get_meta()
4036            .await?
4037            .ok_or(crate::Error::MetadataNotReady(info_hash))
4038    }
4039
4040    async fn run(mut self) {
4041        let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4042        refill_interval.tick().await; // skip first immediate tick
4043
4044        let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4045        let mut auto_manage_interval =
4046            tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4047        auto_manage_interval.tick().await; // skip first immediate tick
4048
4049        // Periodic session stats timer (M50)
4050        let stats_interval_ms = self.settings.stats_report_interval;
4051        let mut stats_timer = if stats_interval_ms > 0 {
4052            Some(tokio::time::interval(std::time::Duration::from_millis(
4053                stats_interval_ms,
4054            )))
4055        } else {
4056            None
4057        };
4058        if let Some(ref mut t) = stats_timer {
4059            t.tick().await; // skip first immediate tick
4060        }
4061
4062        // Periodic sample_infohashes timer (BEP 51, M111)
4063        let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4064        let mut sample_timer = if sample_interval_secs > 0 {
4065            Some(tokio::time::interval(std::time::Duration::from_secs(
4066                sample_interval_secs,
4067            )))
4068        } else {
4069            None
4070        };
4071        if let Some(ref mut t) = sample_timer {
4072            t.tick().await; // skip first immediate tick
4073        }
4074
4075        // Periodic resume file save timer (M161)
4076        let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4077            Some(tokio::time::interval(std::time::Duration::from_secs(
4078                self.settings.save_resume_interval_secs,
4079            )))
4080        } else {
4081            None
4082        };
4083        if let Some(ref mut t) = resume_save_interval {
4084            t.tick().await; // skip first immediate tick
4085        }
4086
4087        // Auto-restore torrents from resume files on startup (M161 Phase 5).
4088        {
4089            let resume_dir = self.effective_resume_dir();
4090            let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4091            if !resume_files.is_empty() {
4092                // Reuse the existing sequential restore logic from Phase 4.
4093                match self.handle_load_resume_state().await {
4094                    Ok(result) => {
4095                        info!(
4096                            restored = result.restored,
4097                            skipped = result.skipped,
4098                            failed = result.failed,
4099                            "auto-restored torrents on startup"
4100                        );
4101                    }
4102                    Err(e) => {
4103                        warn!("auto-restore on startup failed: {e}");
4104                    }
4105                }
4106
4107                // Init throttle: restored torrents were queued inside
4108                // handle_load_resume_state when queueing_enabled. Run one
4109                // immediate evaluate_queue() so up to active_checking
4110                // torrents enter Checking right away (don't wait 30s).
4111                if self.settings.queueing_enabled {
4112                    self.evaluate_queue().await;
4113                }
4114
4115                // Orphan cleanup: delete .resume files whose hex stem does not
4116                // match any info hash currently in the session.
4117                let active_hashes: std::collections::HashSet<String> = self
4118                    .torrents
4119                    .keys()
4120                    .map(|h| hex::encode(h.as_bytes()))
4121                    .collect();
4122
4123                // Re-scan after load — some files may have been consumed.
4124                let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4125                for path in &current_files {
4126                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4127                        && !active_hashes.contains(stem)
4128                    {
4129                        if let Err(e) = std::fs::remove_file(path) {
4130                            warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4131                        } else {
4132                            debug!(path = %path.display(), "removed orphan resume file");
4133                        }
4134                    }
4135                }
4136            }
4137        }
4138
4139        loop {
4140            tokio::select! {
4141                cmd = self.cmd_rx.recv() => {
4142                    // M221.1a — per-command timing. `sent_at` is the
4143                    // instant the sender enqueued the command (via
4144                    // `SessionCmdSender::send`); `recv_at` is now.
4145                    // `queue_wait_ms` measures actor backlog; the
4146                    // post-match `handler_ms` covers the dispatch.
4147                    // Shutdown/None paths return early and skip the
4148                    // tracing emit — telemetry is intentionally absent
4149                    // for the terminal command.
4150                    let recv_at = tokio::time::Instant::now();
4151                    let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4152                        recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4153                    });
4154                    let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4155                    let handler_start = tokio::time::Instant::now();
4156                    let cmd = cmd.map(|(_sent_at, c)| c);
4157                    match cmd {
4158                        Some(SessionCommand::AddTorrent {
4159                            meta,
4160                            storage,
4161                            download_dir,
4162                            reply,
4163                        }) => {
4164                            // M223 — spawn-per-add path. The heavy
4165                            // `disk_manager.register_torrent` +
4166                            // `TorrentHandle::from_torrent` work runs in a
4167                            // `tokio::spawn`'d task; results route back via
4168                            // `SessionCommand::CommitAddTorrent`. Legacy entry
4169                            // point: no tags available from the pre-M171
4170                            // command shape.
4171                            let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4172                                let info_hash = meta.as_v1().map_or_else(
4173                                    || meta.info_hashes().best_v1(),
4174                                    |v| v.info_hash,
4175                                );
4176                                if self.torrents.contains_key(&info_hash) {
4177                                    return Err(crate::Error::DuplicateTorrent(info_hash));
4178                                }
4179                                if self.torrents.len() >= self.settings.max_torrents {
4180                                    return Err(crate::Error::SessionAtCapacity(
4181                                        self.settings.max_torrents,
4182                                    ));
4183                                }
4184                                Ok(self.build_add_torrent_prep_bundle(
4185                                    *meta,
4186                                    storage,
4187                                    download_dir,
4188                                    Vec::new(),
4189                                    None,
4190                                ))
4191                            })();
4192                            match setup {
4193                                Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4194                                Err(e) => {
4195                                    let _ = reply.send(Err(e));
4196                                }
4197                            }
4198                        }
4199                        Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4200                            // M223 — feedback variant: the spawn-per-add prep
4201                            // phase finished. `commit_add_torrent` does the
4202                            // mutating fixup on the actor (insert into
4203                            // `self.torrents`, queue position, alert, LSD,
4204                            // M170 post-hooks) using the precomputed
4205                            // `is_private` from the bundle.
4206                            let id = self.commit_add_torrent(result).await;
4207                            let _ = reply.send(id);
4208                        }
4209                        Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4210                            // Legacy entry point: no tags available from the
4211                            // pre-M171 command shape.
4212                            let result = self
4213                                .handle_add_magnet(magnet, download_dir, Vec::new())
4214                                .await;
4215                            let _ = reply.send(result);
4216                        }
4217                        Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4218                            let result = self.handle_remove_torrent(info_hash).await;
4219                            let _ = reply.send(result);
4220                        }
4221                        Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4222                            let result = self.handle_pause_torrent(info_hash).await;
4223                            let _ = reply.send(result);
4224                        }
4225                        Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4226                            let result = self.handle_resume_torrent(info_hash).await;
4227                            let _ = reply.send(result);
4228                        }
4229                        Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4230                            let result = self.handle_force_resume_torrent(info_hash).await;
4231                            let _ = reply.send(result);
4232                        }
4233                        Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4234                            let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4235                            let _ = reply.send(result);
4236                        }
4237                        Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4238                            let result = self.handle_torrent_stats(info_hash).await;
4239                            let _ = reply.send(result);
4240                        }
4241                        Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4242                            // v0.173.1: handle_torrent_info is now async because
4243                            // it queries the TorrentActor for metadata (Class A
4244                            // architectural fix — no more TorrentEntry.meta cache).
4245                            let result = self.handle_torrent_info(info_hash).await;
4246                            let _ = reply.send(result);
4247                        }
4248                        Some(SessionCommand::ListTorrents { reply }) => {
4249                            let list: Vec<Id20> = self.torrents.keys().copied().collect();
4250                            let _ = reply.send(list);
4251                        }
4252                        Some(SessionCommand::SessionStats { reply }) => {
4253                            let stats = self.make_session_stats().await;
4254                            let _ = reply.send(stats);
4255                        }
4256                        Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4257                            let result = self.handle_save_torrent_resume(info_hash).await;
4258                            let _ = reply.send(result);
4259                        }
4260                        Some(SessionCommand::SaveSessionState { reply }) => {
4261                            let result = self.handle_save_session_state().await;
4262                            let _ = reply.send(result);
4263                        }
4264                        Some(SessionCommand::LoadResumeState { reply }) => {
4265                            let result = self.handle_load_resume_state().await;
4266                            let _ = reply.send(result);
4267                        }
4268                        Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4269                            let result = match self.torrents.get(&info_hash) {
4270                                Some(entry) => Ok(entry.queue_position),
4271                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4272                            };
4273                            let _ = reply.send(result);
4274                        }
4275                        Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4276                            let result = self.handle_set_queue_position(info_hash, pos);
4277                            let _ = reply.send(result);
4278                        }
4279                        Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4280                            let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4281                            let _ = reply.send(result);
4282                        }
4283                        Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4284                            let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4285                            let _ = reply.send(result);
4286                        }
4287                        Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4288                            let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4289                            let _ = reply.send(result);
4290                        }
4291                        Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4292                            let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4293                            let _ = reply.send(result);
4294                        }
4295                        Some(SessionCommand::BanPeer { ip, reply }) => {
4296                            self.ban_manager.write().ban(ip);
4297                            let _ = reply.send(());
4298                        }
4299                        Some(SessionCommand::UnbanPeer { ip, reply }) => {
4300                            let was_banned = self.ban_manager.write().unban(&ip);
4301                            let _ = reply.send(was_banned);
4302                        }
4303                        Some(SessionCommand::BannedPeers { reply }) => {
4304                            let list: Vec<IpAddr> = self.ban_manager.read()
4305                                .banned_list().iter().copied().collect();
4306                            let _ = reply.send(list);
4307                        }
4308                        Some(SessionCommand::SetIpFilter { filter, reply }) => {
4309                            *self.ip_filter.write() = filter;
4310                            let _ = reply.send(());
4311                        }
4312                        Some(SessionCommand::GetIpFilter { reply }) => {
4313                            let filter = self.ip_filter.read().clone();
4314                            let _ = reply.send(filter);
4315                        }
4316                        Some(SessionCommand::GetSettings { reply }) => {
4317                            let _ = reply.send(self.settings.clone());
4318                        }
4319                        Some(SessionCommand::ApplySettings { settings, reply }) => {
4320                            let result = self.handle_apply_settings(*settings);
4321                            let _ = reply.send(result);
4322                        }
4323                        Some(SessionCommand::DhtNodeCount { reply }) => {
4324                            // Sum routing-table sizes across both DHT
4325                            // instances. Either instance erroring or being
4326                            // absent contributes 0 — the qBt wire field is
4327                            // a best-effort gauge, not a strict invariant.
4328                            let mut total: usize = 0;
4329                            if let Some(dht) = &self.dht_v4
4330                                && let Ok(c) = dht.node_count().await
4331                            {
4332                                total += c;
4333                            }
4334                            if let Some(dht) = &self.dht_v6
4335                                && let Ok(c) = dht.node_count().await
4336                            {
4337                                total += c;
4338                            }
4339                            let _ = reply.send(total);
4340                        }
4341                        Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4342                            let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4343                            let _ = reply.send(result);
4344                        }
4345                        Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4346                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4347                                entry.handle.add_peers(peers, source).await
4348                            } else {
4349                                Err(crate::Error::TorrentNotFound(info_hash))
4350                            };
4351                            let _ = reply.send(result);
4352                        }
4353                        Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4354                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4355                                entry.handle.open_file(file_index).await
4356                            } else {
4357                                Err(crate::Error::TorrentNotFound(info_hash))
4358                            };
4359                            let _ = reply.send(result);
4360                        }
4361                        Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4362                            let result = match self.torrents.get(&info_hash) {
4363                                Some(entry) => {
4364                                    entry.handle.force_reannounce().await
4365                                }
4366                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4367                            };
4368                            let _ = reply.send(result);
4369                        }
4370                        Some(SessionCommand::TrackerList { info_hash, reply }) => {
4371                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4372                                entry.handle.tracker_list().await
4373                            } else {
4374                                Err(crate::Error::TorrentNotFound(info_hash))
4375                            };
4376                            let _ = reply.send(result);
4377                        }
4378                        Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4379                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4380                                entry.handle.peer_source_counts().await
4381                            } else {
4382                                Err(crate::Error::TorrentNotFound(info_hash))
4383                            };
4384                            let _ = reply.send(result);
4385                        }
4386                        Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4387                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4388                                entry.handle.query_unchoke_durations().await.ok()
4389                            } else {
4390                                None
4391                            };
4392                            let _ = reply.send(result);
4393                        }
4394                        Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4395                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4396                                entry.handle.get_web_seed_stats().await
4397                            } else {
4398                                Err(crate::Error::TorrentNotFound(info_hash))
4399                            };
4400                            let _ = reply.send(result);
4401                        }
4402                        Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4403                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4404                                entry.handle.get_web_seeds().await
4405                            } else {
4406                                Err(crate::Error::TorrentNotFound(info_hash))
4407                            };
4408                            let _ = reply.send(result);
4409                        }
4410                        Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4411                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4412                                entry.handle.get_piece_states().await
4413                            } else {
4414                                Err(crate::Error::TorrentNotFound(info_hash))
4415                            };
4416                            let _ = reply.send(result);
4417                        }
4418                        Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4419                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4420                                entry.handle.get_piece_hashes(offset, limit).await
4421                            } else {
4422                                Err(crate::Error::TorrentNotFound(info_hash))
4423                            };
4424                            let _ = reply.send(result);
4425                        }
4426                        Some(SessionCommand::Scrape { info_hash, reply }) => {
4427                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4428                                entry.handle.scrape().await
4429                            } else {
4430                                Err(crate::Error::TorrentNotFound(info_hash))
4431                            };
4432                            let _ = reply.send(result);
4433                        }
4434                        Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4435                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4436                                entry.handle.set_file_priority(index, priority).await
4437                            } else {
4438                                Err(crate::Error::TorrentNotFound(info_hash))
4439                            };
4440                            let _ = reply.send(result);
4441                        }
4442                        Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4443                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4444                                entry.handle.file_priorities().await
4445                            } else {
4446                                Err(crate::Error::TorrentNotFound(info_hash))
4447                            };
4448                            let _ = reply.send(result);
4449                        }
4450                        Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4451                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4452                                entry.handle.set_download_limit(bytes_per_sec).await
4453                            } else {
4454                                Err(crate::Error::TorrentNotFound(info_hash))
4455                            };
4456                            let _ = reply.send(result);
4457                        }
4458                        Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4459                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4460                                entry.handle.set_upload_limit(bytes_per_sec).await
4461                            } else {
4462                                Err(crate::Error::TorrentNotFound(info_hash))
4463                            };
4464                            let _ = reply.send(result);
4465                        }
4466                        Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4467                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4468                                entry.handle.download_limit().await
4469                            } else {
4470                                Err(crate::Error::TorrentNotFound(info_hash))
4471                            };
4472                            let _ = reply.send(result);
4473                        }
4474                        Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4475                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4476                                entry.handle.upload_limit().await
4477                            } else {
4478                                Err(crate::Error::TorrentNotFound(info_hash))
4479                            };
4480                            let _ = reply.send(result);
4481                        }
4482                        Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4483                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4484                                entry.handle.set_sequential_download(enabled).await
4485                            } else {
4486                                Err(crate::Error::TorrentNotFound(info_hash))
4487                            };
4488                            let _ = reply.send(result);
4489                        }
4490                        Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4491                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4492                                entry.handle.is_sequential_download().await
4493                            } else {
4494                                Err(crate::Error::TorrentNotFound(info_hash))
4495                            };
4496                            let _ = reply.send(result);
4497                        }
4498                        Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4499                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4500                                entry.handle.set_super_seeding(enabled).await
4501                            } else {
4502                                Err(crate::Error::TorrentNotFound(info_hash))
4503                            };
4504                            let _ = reply.send(result);
4505                        }
4506                        Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4507                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4508                                entry.handle.is_super_seeding().await
4509                            } else {
4510                                Err(crate::Error::TorrentNotFound(info_hash))
4511                            };
4512                            let _ = reply.send(result);
4513                        }
4514                        Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4515                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4516                                entry.handle.set_seed_mode(enabled).await
4517                            } else {
4518                                Err(crate::Error::TorrentNotFound(info_hash))
4519                            };
4520                            let _ = reply.send(result);
4521                        }
4522                        Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4523                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4524                                entry.handle.add_tracker(url).await
4525                            } else {
4526                                Err(crate::Error::TorrentNotFound(info_hash))
4527                            };
4528                            let _ = reply.send(result);
4529                        }
4530                        Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4531                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4532                                entry.handle.replace_trackers(urls).await
4533                            } else {
4534                                Err(crate::Error::TorrentNotFound(info_hash))
4535                            };
4536                            let _ = reply.send(result);
4537                        }
4538                        Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4539                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4540                                entry.handle.force_recheck().await
4541                            } else {
4542                                Err(crate::Error::TorrentNotFound(info_hash))
4543                            };
4544                            let _ = reply.send(result);
4545                        }
4546                        Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4547                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4548                                entry.handle.rename_file(file_index, new_name).await
4549                            } else {
4550                                Err(crate::Error::TorrentNotFound(info_hash))
4551                            };
4552                            let _ = reply.send(result);
4553                        }
4554                        Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4555                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4556                                entry.handle.set_max_connections(limit).await
4557                            } else {
4558                                Err(crate::Error::TorrentNotFound(info_hash))
4559                            };
4560                            let _ = reply.send(result);
4561                        }
4562                        Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4563                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4564                                entry.handle.max_connections().await
4565                            } else {
4566                                Err(crate::Error::TorrentNotFound(info_hash))
4567                            };
4568                            let _ = reply.send(result);
4569                        }
4570                        Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4571                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4572                                entry.handle.set_max_uploads(limit).await
4573                            } else {
4574                                Err(crate::Error::TorrentNotFound(info_hash))
4575                            };
4576                            let _ = reply.send(result);
4577                        }
4578                        Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4579                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4580                                entry.handle.max_uploads().await
4581                            } else {
4582                                Err(crate::Error::TorrentNotFound(info_hash))
4583                            };
4584                            let _ = reply.send(result);
4585                        }
4586                        Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4587                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4588                                entry.handle.get_peer_info().await
4589                            } else {
4590                                Err(crate::Error::TorrentNotFound(info_hash))
4591                            };
4592                            let _ = reply.send(result);
4593                        }
4594                        Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4595                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4596                                entry.handle.get_download_queue().await
4597                            } else {
4598                                Err(crate::Error::TorrentNotFound(info_hash))
4599                            };
4600                            let _ = reply.send(result);
4601                        }
4602                        Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4603                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4604                                entry.handle.have_piece(index).await
4605                            } else {
4606                                Err(crate::Error::TorrentNotFound(info_hash))
4607                            };
4608                            let _ = reply.send(result);
4609                        }
4610                        Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4611                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4612                                entry.handle.piece_availability().await
4613                            } else {
4614                                Err(crate::Error::TorrentNotFound(info_hash))
4615                            };
4616                            let _ = reply.send(result);
4617                        }
4618                        Some(SessionCommand::FileProgress { info_hash, reply }) => {
4619                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4620                                entry.handle.file_progress().await
4621                            } else {
4622                                Err(crate::Error::TorrentNotFound(info_hash))
4623                            };
4624                            let _ = reply.send(result);
4625                        }
4626                        Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4627                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4628                                entry.handle.info_hashes().await
4629                            } else {
4630                                Err(crate::Error::TorrentNotFound(info_hash))
4631                            };
4632                            let _ = reply.send(result);
4633                        }
4634                        Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4635                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4636                                entry.handle.torrent_file().await
4637                            } else {
4638                                Err(crate::Error::TorrentNotFound(info_hash))
4639                            };
4640                            let _ = reply.send(result);
4641                        }
4642                        Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4643                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4644                                entry.handle.torrent_file_v2().await
4645                            } else {
4646                                Err(crate::Error::TorrentNotFound(info_hash))
4647                            };
4648                            let _ = reply.send(result);
4649                        }
4650                        #[cfg(feature = "test-util")]
4651                        Some(SessionCommand::TestInjectMetadata {
4652                            info_hash,
4653                            info_bytes,
4654                            reply,
4655                        }) => {
4656                            let result = match self.torrents.get(&info_hash) {
4657                                Some(entry) => {
4658                                    entry.handle.test_inject_metadata(info_bytes).await
4659                                }
4660                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4661                            };
4662                            let _ = reply.send(result);
4663                        }
4664                        Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4665                            // v0.173.2: BEP 27 enforcement on the DHT path. M173.1 fixed the
4666                            // LSD leak vector but missed DHT — private torrents would still
4667                            // announce their info hash to DHT and leak peer IPs. Mirrors the
4668                            // LSD pattern at session.rs:3541-3563.
4669                            let result = match self.torrents.get(&info_hash) {
4670                                Some(entry) => {
4671                                    if entry.is_private().await {
4672                                        Err(crate::Error::InvalidSettings(
4673                                            "DHT disabled for private torrent".into(),
4674                                        ))
4675                                    } else {
4676                                        entry.handle.force_dht_announce().await
4677                                    }
4678                                }
4679                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4680                            };
4681                            let _ = reply.send(result);
4682                        }
4683                        Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4684                            // LSD is session-level: verify the torrent exists, then announce directly.
4685                            //
4686                            // v0.173.1: is_private is now async. Match guards can't be async, so
4687                            // evaluate the flag up front and branch on the bool.
4688                            let result = match self.torrents.get(&info_hash) {
4689                                Some(entry) => {
4690                                    if entry.is_private().await {
4691                                        // BEP 27: private torrents must not use LSD
4692                                        Err(crate::Error::InvalidSettings(
4693                                            "LSD disabled for private torrent".into(),
4694                                        ))
4695                                    } else {
4696                                        if let Some(ref lsd) = self.lsd {
4697                                            lsd.announce(vec![info_hash]).await;
4698                                        }
4699                                        Ok(())
4700                                    }
4701                                }
4702                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4703                            };
4704                            let _ = reply.send(result);
4705                        }
4706                        Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4707                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4708                                entry.handle.read_piece(index).await
4709                            } else {
4710                                Err(crate::Error::TorrentNotFound(info_hash))
4711                            };
4712                            let _ = reply.send(result);
4713                        }
4714                        Some(SessionCommand::FlushCache { info_hash, reply }) => {
4715                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4716                                entry.handle.flush_cache().await
4717                            } else {
4718                                Err(crate::Error::TorrentNotFound(info_hash))
4719                            };
4720                            let _ = reply.send(result);
4721                        }
4722                        Some(SessionCommand::IsValid { info_hash, reply }) => {
4723                            let valid = self.torrents.get(&info_hash)
4724                                .is_some_and(|e| e.handle.is_valid());
4725                            let _ = reply.send(valid);
4726                        }
4727                        Some(SessionCommand::ClearError { info_hash, reply }) => {
4728                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4729                                entry.handle.clear_error().await
4730                            } else {
4731                                Err(crate::Error::TorrentNotFound(info_hash))
4732                            };
4733                            let _ = reply.send(result);
4734                        }
4735                        Some(SessionCommand::FileStatus { info_hash, reply }) => {
4736                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4737                                entry.handle.file_status().await
4738                            } else {
4739                                Err(crate::Error::TorrentNotFound(info_hash))
4740                            };
4741                            let _ = reply.send(result);
4742                        }
4743                        Some(SessionCommand::Flags { info_hash, reply }) => {
4744                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4745                                entry.handle.flags().await
4746                            } else {
4747                                Err(crate::Error::TorrentNotFound(info_hash))
4748                            };
4749                            let _ = reply.send(result);
4750                        }
4751                        Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
4752                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4753                                entry.handle.set_flags(flags).await
4754                            } else {
4755                                Err(crate::Error::TorrentNotFound(info_hash))
4756                            };
4757                            let _ = reply.send(result);
4758                        }
4759                        Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
4760                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4761                                entry.handle.unset_flags(flags).await
4762                            } else {
4763                                Err(crate::Error::TorrentNotFound(info_hash))
4764                            };
4765                            let _ = reply.send(result);
4766                        }
4767                        Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
4768                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4769                                entry.handle.connect_peer(addr).await
4770                            } else {
4771                                Err(crate::Error::TorrentNotFound(info_hash))
4772                            };
4773                            let _ = reply.send(result);
4774                        }
4775                        Some(SessionCommand::DhtPutImmutable { value, reply }) => {
4776                            let result = self.handle_dht_put_immutable(value).await;
4777                            let _ = reply.send(result);
4778                        }
4779                        Some(SessionCommand::DhtGetImmutable { target, reply }) => {
4780                            let result = self.handle_dht_get_immutable(target).await;
4781                            let _ = reply.send(result);
4782                        }
4783                        Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
4784                            let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
4785                            let _ = reply.send(result);
4786                        }
4787                        Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
4788                            let result = self.handle_dht_get_mutable(public_key, salt).await;
4789                            let _ = reply.send(result);
4790                        }
4791                        Some(SessionCommand::PostSessionStats) => {
4792                            self.fire_stats_alert();
4793                        }
4794                        Some(SessionCommand::SaveResumeState { reply }) => {
4795                            // M241 L1: run the save OFF the recv loop. The spawned
4796                            // task owns the reply and answers the caller once the
4797                            // write completes, so a slow disk / many torrents no
4798                            // longer freeze the whole session. It waits on
4799                            // resume_save_lock (lock_owned) so it never races an
4800                            // in-flight periodic save onto the same temp path (F4);
4801                            // unlike the periodic tick it always eventually persists,
4802                            // honouring the caller's explicit "save now" intent.
4803                            let lock = Arc::clone(&self.resume_save_lock);
4804                            let (resume_dir, jobs) = self.snapshot_resume_jobs();
4805                            tokio::spawn(async move {
4806                                let _guard = lock.lock_owned().await;
4807                                let count = run_resume_save_jobs(resume_dir, jobs).await;
4808                                let _ = reply.send(Ok(count));
4809                            });
4810                        }
4811                        Some(SessionCommand::AddTorrentM170 { params, reply }) => {
4812                            // M223 — bytes branch goes through the
4813                            // spawn-per-add path; magnet branch stays inline.
4814                            // The dispatcher consumes `reply` directly.
4815                            self.dispatch_add_torrent_m170(*params, reply).await;
4816                        }
4817                        Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
4818                            let result = self.handle_create_category(name, save_path).await;
4819                            let _ = reply.send(result);
4820                        }
4821                        Some(SessionCommand::EditCategory { name, save_path, reply }) => {
4822                            let result = self.handle_edit_category(name, save_path).await;
4823                            let _ = reply.send(result);
4824                        }
4825                        Some(SessionCommand::RemoveCategories { names, reply }) => {
4826                            let result = self.handle_remove_categories(names).await;
4827                            let _ = reply.send(result);
4828                        }
4829                        Some(SessionCommand::ListCategories { reply }) => {
4830                            let snapshot = self.category_registry.read().list();
4831                            let _ = reply.send(snapshot);
4832                        }
4833                        Some(SessionCommand::CreateTags { names, reply }) => {
4834                            let results: Vec<_> = {
4835                                let mut reg = self.tag_registry.write();
4836                                names.into_iter().map(|n| reg.create(n)).collect()
4837                            };
4838                            // Persist any successful creates. Persistence failures
4839                            // warn but don't change the per-call reply (matches
4840                            // CreateCategory).
4841                            if let Err(e) = self.persist_tag_registry().await {
4842                                tracing::warn!(
4843                                    error = %e,
4844                                    "failed to persist tag registry after CreateTags"
4845                                );
4846                            }
4847                            let _ = reply.send(results);
4848                        }
4849                        Some(SessionCommand::DeleteTags { names, reply }) => {
4850                            let removed = self.handle_delete_tags(names).await;
4851                            let _ = reply.send(removed);
4852                        }
4853                        Some(SessionCommand::ListTags { reply }) => {
4854                            let names = self.tag_registry.read().list();
4855                            let _ = reply.send(names);
4856                        }
4857                        Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
4858                            let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
4859                            let _ = reply.send(res);
4860                        }
4861                        Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
4862                            let res = self
4863                                .handle_remove_tags_from_torrents(info_hashes, tags)
4864                                .await;
4865                            let _ = reply.send(res);
4866                        }
4867                        Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
4868                            let result = self.handle_remove_torrent_with_files(info_hash).await;
4869                            let _ = reply.send(result);
4870                        }
4871                        Some(SessionCommand::DebugState { reply }) => {
4872                            let state = self.make_debug_state().await;
4873                            let _ = reply.send(state);
4874                        }
4875                        Some(SessionCommand::Shutdown) | None => {
4876                            self.shutdown_all().await;
4877                            return;
4878                        }
4879                    }
4880                    // M221.1a — emit per-command timing on the
4881                    // bench-harness target. Filtering happens in the
4882                    // tracing subscriber (parallel-7 harness sets
4883                    // RUST_LOG=irontide_session::cmd_timing=info); the
4884                    // emit is unconditional so any external consumer
4885                    // can opt in.
4886                    let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
4887                    info!(
4888                        target: "irontide_session::cmd_timing",
4889                        cmd = cmd_name,
4890                        queue_wait_ms = queue_wait_ms,
4891                        handler_ms = handler_ms,
4892                        "session_cmd"
4893                    );
4894                }
4895                result = async {
4896                    match &mut self.lsd_peers_rx {
4897                        Some(rx) => rx.recv().await,
4898                        None => std::future::pending().await,
4899                    }
4900                } => {
4901                    if let Some((info_hash, peer_addr)) = result
4902                        && let Some(entry) = self.torrents.get(&info_hash)
4903                    {
4904                        // v0.173.1: is_private is async — can't chain it into the let-else
4905                        // condition list, evaluate separately before add_peers.
4906                        let is_priv = entry.is_private().await;
4907                        if !is_priv {
4908                            // BEP 27: reject LSD peers for private torrents
4909                            let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
4910                        }
4911                    }
4912                }
4913                // Pre-validated inbound connections from ListenerTask (M114)
4914                Some(conn) = self.validated_conn_rx.recv() => {
4915                    self.handle_identified_inbound(conn);
4916                }
4917                // SSL inbound connections (M42)
4918                result = async {
4919                    if let Some(ref mut listener) = self.ssl_listener {
4920                        listener.accept().await
4921                    } else {
4922                        std::future::pending().await
4923                    }
4924                } => {
4925                    if let Ok((stream, addr)) = result {
4926                        self.handle_ssl_incoming(stream, addr).await;
4927                    }
4928                }
4929                // Global rate limiter refill (100ms)
4930                _ = refill_interval.tick() => {
4931                    let elapsed = std::time::Duration::from_millis(100);
4932                    self.global_upload_bucket.lock().refill(elapsed);
4933                    self.global_download_bucket.lock().refill(elapsed);
4934                }
4935                // Auto-manage queue evaluation
4936                _ = auto_manage_interval.tick() => {
4937                    self.evaluate_queue().await;
4938                }
4939                // Checking-complete trigger: when a torrent exits Checking
4940                // state, re-evaluate immediately so the next candidate
4941                // promotes without waiting for the 30s periodic tick.
4942                alert = self.self_alert_rx.recv() => {
4943                    if let Ok(alert) = alert
4944                        && matches!(
4945                            alert.kind,
4946                            AlertKind::StateChanged {
4947                                prev_state: TorrentState::Checking,
4948                                new_state,
4949                                ..
4950                            } if new_state != TorrentState::Checking
4951                        )
4952                    {
4953                        self.evaluate_queue().await;
4954                    }
4955                }
4956                // NAT port mapping events
4957                event = recv_nat_event(&mut self.nat_events_rx) => {
4958                    match event {
4959                        irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
4960                            info!(port, %protocol, "port mapping succeeded");
4961                            post_alert(
4962                                &self.alert_tx,
4963                                &self.alert_mask,
4964                                AlertKind::PortMappingSucceeded { port, protocol },
4965                            );
4966                        }
4967                        irontide_nat::NatEvent::MappingFailed { port, message } => {
4968                            warn!(port, %message, "port mapping failed");
4969                            post_alert(
4970                                &self.alert_tx,
4971                                &self.alert_mask,
4972                                AlertKind::PortMappingFailed { port, message },
4973                            );
4974                        }
4975                        irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
4976                            info!(%ip, "external IP discovered via NAT traversal");
4977                            self.external_ip = Some(ip);
4978                            // Propagate to all active torrents for BEP 40 peer priority.
4979                            for entry in self.torrents.values() {
4980                                let _ = entry.handle.update_external_ip(ip).await;
4981                            }
4982                            // BEP 42: notify DHT instances of external IP
4983                            if let Some(dht) = &self.dht_v4 {
4984                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
4985                            }
4986                            if let Some(dht) = &self.dht_v6 {
4987                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
4988                            }
4989                        }
4990                    }
4991                }
4992                // BEP 42: DHT v4 external IP consensus
4993                Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
4994                    info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
4995                    self.external_ip = Some(ip);
4996                    for entry in self.torrents.values() {
4997                        let _ = entry.handle.update_external_ip(ip).await;
4998                    }
4999                }
5000                // BEP 42: DHT v6 external IP consensus
5001                Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5002                    info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5003                    self.external_ip = Some(ip);
5004                    for entry in self.torrents.values() {
5005                        let _ = entry.handle.update_external_ip(ip).await;
5006                    }
5007                }
5008                // Periodic session stats (M50)
5009                _ = async {
5010                    match &mut stats_timer {
5011                        Some(t) => t.tick().await,
5012                        None => std::future::pending().await,
5013                    }
5014                } => {
5015                    // M245 A1 (phase-4 grounding fix): refresh the published
5016                    // read-model on the SAME periodic cadence that fires the
5017                    // stats alert. The ratified plan assumed `make_session_stats`
5018                    // ran on this tick, but it only ran on-demand via
5019                    // `SessionCommand::SessionStats`. Without this, a client that
5020                    // polls `list_torrent_summaries` (P1) but not `session_stats`
5021                    // would see the snapshot's sampled fields (state, rates,
5022                    // progress) frozen at each torrent's add-time eager publish —
5023                    // membership stayed correct, but state never advanced. Reuse
5024                    // the single bounded fan-out (C1) for its snapshot side
5025                    // effect; the alert below stays counter-based, so the summed
5026                    // result is intentionally discarded.
5027                    let _ = self.make_session_stats().await;
5028                    self.fire_stats_alert();
5029                }
5030                // Periodic sample_infohashes (BEP 51, M111)
5031                _ = async {
5032                    match &mut sample_timer {
5033                        Some(t) => t.tick().await,
5034                        None => std::future::pending().await,
5035                    }
5036                } => {
5037                    self.fire_sample_infohashes().await;
5038                }
5039                // Periodic resume file save (M161) — runs OFF the recv loop (M241 L1).
5040                _ = async {
5041                    match &mut resume_save_interval {
5042                        Some(t) => t.tick().await,
5043                        None => std::future::pending().await,
5044                    }
5045                } => {
5046                    // try_lock_owned: if a save is already in flight (this tick's
5047                    // predecessor on a slow disk, or a SaveResumeState RPC), skip —
5048                    // don't pile up concurrent writers onto the same temp path (F4).
5049                    match Arc::clone(&self.resume_save_lock).try_lock_owned() {
5050                        Ok(guard) => {
5051                            let (resume_dir, jobs) = self.snapshot_resume_jobs();
5052                            tokio::spawn(async move {
5053                                let _guard = guard;
5054                                let count = run_resume_save_jobs(resume_dir, jobs).await;
5055                                if count > 0 {
5056                                    info!(count, "periodic resume save completed");
5057                                }
5058                            });
5059                        }
5060                        Err(_) => {
5061                            debug!("resume save already in flight — skipping this periodic tick");
5062                        }
5063                    }
5064                }
5065                // Periodic resume save interval rebuild (M225)
5066                () = self.resume_save_notify.notified() => {
5067                    resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5068                        Some(tokio::time::interval(std::time::Duration::from_secs(
5069                            self.settings.save_resume_interval_secs,
5070                        )))
5071                    } else {
5072                        None
5073                    };
5074                    if let Some(ref mut t) = resume_save_interval {
5075                        t.tick().await; // skip first immediate tick
5076                    }
5077                }
5078            }
5079        }
5080    }
5081
5082    /// Return clones of global buckets if they have a non-zero rate, else None.
5083    fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5084        let up = if self.settings.upload_rate_limit > 0 {
5085            Some(Arc::clone(&self.global_upload_bucket))
5086        } else {
5087            None
5088        };
5089        let down = if self.settings.download_rate_limit > 0 {
5090            Some(Arc::clone(&self.global_download_bucket))
5091        } else {
5092            None
5093        };
5094        (up, down)
5095    }
5096
5097    fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5098        if self.settings.auto_upload_slots {
5099            crate::slot_tuner::SlotTuner::new(
5100                4, // initial slots
5101                self.settings.auto_upload_slots_min,
5102                self.settings.auto_upload_slots_max,
5103            )
5104        } else {
5105            crate::slot_tuner::SlotTuner::disabled(4)
5106        }
5107    }
5108
5109    fn make_torrent_config(&self) -> TorrentConfig {
5110        TorrentConfig::from(&self.settings)
5111    }
5112
5113    /// Returns the next available queue position (one past the max).
5114    fn next_queue_position(&self) -> i32 {
5115        self.torrents
5116            .values()
5117            .filter(|e| e.auto_managed)
5118            .map(|e| e.queue_position)
5119            .max()
5120            .map_or(0, |m| m + 1)
5121    }
5122
5123    /// Inline add-torrent path: builds the prep bundle on the actor,
5124    /// runs prepare + commit phases sequentially without spawning. Used
5125    /// by the resume-restore startup path (single-threaded by
5126    /// construction) and the `handle_add_torrent_with_params` M170
5127    /// helper. The `AddTorrent` and `AddTorrentM170` recv arms call
5128    /// `try_spawn_add_torrent` instead — same prep + commit primitives,
5129    /// but the prep runs in a `tokio::spawn` and the commit hop returns
5130    /// via `SessionCommand::CommitAddTorrent` (M223).
5131    async fn handle_add_torrent(
5132        &mut self,
5133        torrent_meta: irontide_core::TorrentMeta,
5134        storage: Option<Arc<dyn TorrentStorage>>,
5135        download_dir: Option<PathBuf>,
5136        tags: Vec<String>,
5137    ) -> crate::Result<Id20> {
5138        let info_hash = torrent_meta
5139            .as_v1()
5140            .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5141        if self.torrents.contains_key(&info_hash) {
5142            return Err(crate::Error::DuplicateTorrent(info_hash));
5143        }
5144        if self.torrents.len() >= self.settings.max_torrents {
5145            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5146        }
5147        let bundle =
5148            self.build_add_torrent_prep_bundle(torrent_meta, storage, download_dir, tags, None);
5149        let prep = prepare_add_torrent_off_actor(bundle).await;
5150        self.commit_add_torrent(prep).await
5151    }
5152
5153    /// M223 — build the off-actor add-torrent prep bundle synchronously
5154    /// from session state. Cheap Arc-clones + a single
5155    /// `make_torrent_config` snapshot; never awaits, so it stays on the
5156    /// actor without contributing to the queue-wait stack.
5157    fn build_add_torrent_prep_bundle(
5158        &self,
5159        torrent_meta: irontide_core::TorrentMeta,
5160        storage: Option<Arc<dyn TorrentStorage>>,
5161        download_dir: Option<PathBuf>,
5162        tags: Vec<String>,
5163        m170_post: Option<M170PostAdd>,
5164    ) -> AddTorrentPrepBundle {
5165        let mut torrent_config = self.make_torrent_config();
5166        if let Some(dir) = download_dir {
5167            torrent_config.download_dir = dir;
5168        }
5169        // M171: bake tags into the config BEFORE the actor is constructed so
5170        // the first `stats()` snapshot already carries them — no post-add
5171        // spawn race. An empty vec is a no-op but still replaces any
5172        // session-level default (currently also empty).
5173        torrent_config.tags = tags;
5174
5175        let (global_up, global_down) = self.global_buckets_if_limited();
5176        let slot_tuner = self.make_slot_tuner();
5177
5178        AddTorrentPrepBundle {
5179            torrent_meta,
5180            storage_override: storage,
5181            torrent_config,
5182            disk_manager: self.disk_manager.clone(),
5183            dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5184            dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5185            global_up,
5186            global_down,
5187            slot_tuner,
5188            alert_tx: self.alert_tx.clone(),
5189            alert_mask: Arc::clone(&self.alert_mask),
5190            utp_socket: self.utp_socket.clone(),
5191            utp_socket_v6: self.utp_socket_v6.clone(),
5192            ban_manager: Arc::clone(&self.ban_manager),
5193            ip_filter: Arc::clone(&self.ip_filter),
5194            plugins: Arc::clone(&self.plugins),
5195            sam_session: self.sam_session.clone(),
5196            ssl_manager: self.ssl_manager.clone(),
5197            factory: Arc::clone(&self.factory),
5198            hash_pool: Arc::clone(&self.hash_pool),
5199            counters: Arc::clone(&self.counters),
5200            m170_post,
5201        }
5202    }
5203
5204    /// M223 — commit phase of the spawn-per-add fix. Mutates session
5205    /// state (insert into `self.torrents`, info-hash registry, queue
5206    /// position, alert, LSD announce, M170 post-hooks) using the
5207    /// success-path payload from `prepare_add_torrent_off_actor`. On
5208    /// `Err` from prep, returns the error untouched.
5209    async fn commit_add_torrent(
5210        &mut self,
5211        prep: crate::Result<PreparedAddTorrent>,
5212    ) -> crate::Result<Id20> {
5213        let PreparedAddTorrent {
5214            handle,
5215            info_hash,
5216            is_private,
5217            m170_post,
5218        } = prep?;
5219        // Re-check dup + capacity: parallel adds may both pass the
5220        // pre-spawn check, then race the commit. The later one fails
5221        // here, and dropping `handle` triggers the spawned TorrentActor's
5222        // graceful shutdown (its cmd_tx receiver hangs up → recv returns
5223        // None → actor exits).
5224        if self.torrents.contains_key(&info_hash) {
5225            drop(handle);
5226            return Err(crate::Error::DuplicateTorrent(info_hash));
5227        }
5228        if self.torrents.len() >= self.settings.max_torrents {
5229            drop(handle);
5230            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5231        }
5232        self.torrents.insert(
5233            info_hash,
5234            TorrentEntry {
5235                handle,
5236                queue_position: -1,
5237                auto_managed: true,
5238                started_at: Some(tokio::time::Instant::now()),
5239                smoothed_download_rate: f64::MAX,
5240                smoothed_upload_rate: f64::MAX,
5241            },
5242        );
5243        self.info_hash_registry.insert(info_hash, ());
5244
5245        // Assign queue position for auto-managed torrents
5246        let pos = self.next_queue_position();
5247        if let Some(entry) = self.torrents.get_mut(&info_hash)
5248            && entry.auto_managed
5249        {
5250            entry.queue_position = pos;
5251        }
5252
5253        info!(%info_hash, "torrent added to session");
5254        // M223 — `TorrentAdded` is posted in `prepare_add_torrent_off_actor`
5255        // (BEFORE the `commit_tx.send.await` yield) to preserve the alert
5256        // ordering invariant under spawn-per-add. See the comment there.
5257        // BEP 27: private torrents must not use LSD. `is_private` is
5258        // precomputed from `meta.info.private == Some(1)` in the prep
5259        // bundle, so the commit arm needs no async query.
5260        if let Some(ref lsd) = self.lsd
5261            && !is_private
5262        {
5263            lsd.announce(vec![info_hash]).await;
5264        }
5265        // M170 post-add hooks (category + paused-on-add) for the
5266        // `AddTorrentM170` path. Always `None` for the legacy
5267        // `AddTorrent` path.
5268        if let Some(M170PostAdd { category, paused }) = m170_post {
5269            self.apply_post_add_m170(info_hash, category, paused);
5270        }
5271        // M245 A1 — eager membership publish. This is the SOLE non-magnet
5272        // insert site: it covers spawn-per-add commits AND startup restore
5273        // (`handle_load_resume_state` → `handle_add_torrent` → here), so reads
5274        // see the torrent the instant its add commits.
5275        self.snapshot_publish_one(info_hash).await;
5276        Ok(info_hash)
5277    }
5278
5279    /// M223 — recv-arm helper: spawn the prep phase off the actor,
5280    /// route the result back via `CommitAddTorrent`. Consumes `reply`,
5281    /// so callers must not also send to it.
5282    fn try_spawn_add_torrent(
5283        &self,
5284        bundle: AddTorrentPrepBundle,
5285        reply: oneshot::Sender<crate::Result<Id20>>,
5286    ) {
5287        let commit_tx = self.commit_tx.clone();
5288        tokio::spawn(async move {
5289            let result = prepare_add_torrent_off_actor(bundle).await;
5290            if commit_tx
5291                .send(SessionCommand::CommitAddTorrent { result, reply })
5292                .await
5293                .is_err()
5294            {
5295                // Session is shutting down; the receiver is gone too.
5296                // The `reply` oneshot was moved into the variant which
5297                // got dropped, so the original caller observes a closed
5298                // channel — the right shutdown signal.
5299                warn!("M223 prep task: commit_tx send failed (session shutting down)");
5300            }
5301        });
5302    }
5303
5304    async fn handle_add_magnet(
5305        &mut self,
5306        magnet: Magnet,
5307        download_dir: Option<PathBuf>,
5308        tags: Vec<String>,
5309    ) -> crate::Result<Id20> {
5310        let info_hash = magnet.info_hash();
5311        let display_name = magnet.display_name.clone().unwrap_or_default();
5312        if self.torrents.contains_key(&info_hash) {
5313            return Err(crate::Error::DuplicateTorrent(info_hash));
5314        }
5315        if self.torrents.len() >= self.settings.max_torrents {
5316            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5317        }
5318        let mut config = self.make_torrent_config();
5319        if let Some(dir) = download_dir {
5320            config.download_dir = dir;
5321        }
5322        // M171: bake tags into the config BEFORE the actor is constructed so
5323        // the first `stats()` snapshot already carries them — no post-add
5324        // spawn race. An empty vec is a no-op but still replaces any
5325        // session-level default (currently also empty).
5326        config.tags = tags;
5327        let (global_up, global_down) = self.global_buckets_if_limited();
5328        let slot_tuner = self.make_slot_tuner();
5329        let handle = TorrentHandle::from_magnet(
5330            magnet,
5331            self.disk_manager.clone(),
5332            config,
5333            self.dht_v4_broadcast.subscribe(),
5334            self.dht_v6_broadcast.subscribe(),
5335            global_up,
5336            global_down,
5337            slot_tuner,
5338            self.alert_tx.clone(),
5339            Arc::clone(&self.alert_mask),
5340            self.utp_socket.clone(),
5341            self.utp_socket_v6.clone(),
5342            Arc::clone(&self.ban_manager),
5343            Arc::clone(&self.ip_filter),
5344            Arc::clone(&self.plugins),
5345            self.sam_session.clone(),
5346            self.ssl_manager.clone(),
5347            Arc::clone(&self.factory),
5348            Some(Arc::clone(&self.hash_pool)),
5349            Arc::clone(&self.counters),
5350        )
5351        .await?;
5352        // M147: Spawn background metadata resolver before registering.
5353        // This races against the TorrentActor's own FetchingMetadata phase —
5354        // first to resolve wins.
5355        self.spawn_metadata_resolver(info_hash, &handle);
5356
5357        self.torrents.insert(
5358            info_hash,
5359            TorrentEntry {
5360                handle,
5361                queue_position: -1,
5362                auto_managed: true,
5363                started_at: Some(tokio::time::Instant::now()),
5364                smoothed_download_rate: f64::MAX,
5365                smoothed_upload_rate: f64::MAX,
5366            },
5367        );
5368        self.info_hash_registry.insert(info_hash, ());
5369
5370        // Assign queue position for auto-managed torrents
5371        let pos = self.next_queue_position();
5372        if let Some(entry) = self.torrents.get_mut(&info_hash)
5373            && entry.auto_managed
5374        {
5375            entry.queue_position = pos;
5376        }
5377
5378        info!(%info_hash, "magnet torrent added to session");
5379        post_alert(
5380            &self.alert_tx,
5381            &self.alert_mask,
5382            AlertKind::TorrentAdded {
5383                info_hash,
5384                name: display_name,
5385            },
5386        );
5387        // BEP 27: magnet metadata not available yet — we allow this one-time LAN
5388        // announce. Once metadata resolves, all subsequent LSD ops are gated by
5389        // is_private() checks in ForceLsdAnnounce and lsd_peers_rx handlers.
5390        if let Some(ref lsd) = self.lsd {
5391            lsd.announce(vec![info_hash]).await;
5392        }
5393        // M245 A1 — eager membership publish (magnet add site). At this point
5394        // metadata is unresolved, so the published summary carries the magnet's
5395        // display name + zeroed size/rates; the tick refreshes it once the
5396        // actor reports real stats.
5397        self.snapshot_publish_one(info_hash).await;
5398        Ok(info_hash)
5399    }
5400
5401    /// M147: Spawn a background task that pre-resolves magnet metadata via DHT.
5402    ///
5403    /// The resolver connects to peers discovered via DHT `get_peers`, performs
5404    /// BT + BEP 10 extension + BEP 9 `ut_metadata` exchanges, and sends the
5405    /// assembled metadata back to the `TorrentActor` via `PreResolvedMetadata`.
5406    /// This races against the `TorrentActor`'s own `FetchingMetadata` phase.
5407    fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5408        let dht = match self.dht_v4 {
5409            Some(ref dht) => dht.clone(),
5410            None => return, // No DHT = skip background resolution
5411        };
5412        let factory = Arc::clone(&self.factory);
5413        let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5414        let handle = torrent_handle.clone();
5415
5416        tokio::spawn(async move {
5417            let peer_rx = match dht.get_peers(info_hash).await {
5418                Ok(rx) => rx,
5419                Err(e) => {
5420                    debug!(
5421                        %info_hash,
5422                        "metadata resolver: failed to start DHT get_peers: {e}"
5423                    );
5424                    return;
5425                }
5426            };
5427
5428            let peer_id = irontide_core::PeerId::generate().0;
5429            match crate::metadata_resolver::resolve_metadata(
5430                info_hash,
5431                peer_id,
5432                peer_rx,
5433                factory,
5434                connect_timeout,
5435                crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5436            )
5437            .await
5438            {
5439                Ok((meta, peers)) => {
5440                    let info_bytes = if let Some(b) = meta.info_bytes {
5441                        b.to_vec()
5442                    } else {
5443                        match irontide_bencode::to_bytes(&meta.info) {
5444                            Ok(bytes) => bytes,
5445                            Err(e) => {
5446                                debug!(
5447                                    %info_hash,
5448                                    "metadata resolver: failed to re-encode info dict: {e}"
5449                                );
5450                                return;
5451                            }
5452                        }
5453                    };
5454                    debug!(
5455                        %info_hash,
5456                        num_peers = peers.len(),
5457                        "metadata resolver: pre-resolved metadata, sending to torrent actor"
5458                    );
5459                    handle.send_pre_resolved_metadata(info_bytes, peers);
5460                }
5461                Err(e) => {
5462                    debug!(
5463                        %info_hash,
5464                        "metadata resolver: failed to resolve metadata: {e}"
5465                    );
5466                }
5467            }
5468        });
5469    }
5470
5471    async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5472        let entry = self
5473            .torrents
5474            .remove(&info_hash)
5475            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5476        self.info_hash_registry.remove(&info_hash);
5477        // M245 A1 — eager membership drop. This is the SOLE remove site:
5478        // `handle_remove_torrent_with_files` delegates here, so reads stop
5479        // seeing the torrent the instant its removal commits.
5480        self.snapshot_drop_one(info_hash);
5481        let was_auto_managed = entry.auto_managed;
5482        let removed_position = entry.queue_position;
5483        entry.handle.shutdown().await?;
5484        self.disk_manager.unregister_torrent(info_hash).await;
5485
5486        // Shift queue positions for remaining auto-managed torrents
5487        if was_auto_managed && removed_position >= 0 {
5488            let mut entries = self.queue_entries();
5489            let changed = crate::queue::remove_position(&mut entries, removed_position);
5490            self.apply_queue_changes(&changed);
5491        }
5492
5493        // Delete the resume file for this torrent so it is not restored
5494        // on the next startup. Errors are logged but not propagated — the
5495        // torrent is already removed from the in-memory state.
5496        let resume_dir = self.effective_resume_dir();
5497        if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5498            // NotFound is expected when no resume file was ever written.
5499            if e.kind() != std::io::ErrorKind::NotFound {
5500                warn!(%info_hash, "failed to delete resume file on removal: {e}");
5501            }
5502        }
5503
5504        info!(%info_hash, "torrent removed from session");
5505        post_alert(
5506            &self.alert_tx,
5507            &self.alert_mask,
5508            AlertKind::TorrentRemoved { info_hash },
5509        );
5510        Ok(())
5511    }
5512
5513    // ── M170 handlers ──────────────────────────────────────────────────
5514
5515    /// M170: unified add entry. Parses magnet or bytes, resolves the
5516    /// download directory via category precedence, and dispatches to the
5517    /// existing add paths.
5518    /// M223 — dispatcher for the `AddTorrentM170` recv arm. The bytes
5519    /// branch goes through the spawn-per-add path
5520    /// (`try_spawn_add_torrent`); the magnet branch stays inline because
5521    /// it is dominated by metadata fetch latency, not by
5522    /// `TorrentHandle::from_torrent` cost, and is out of M223 scope.
5523    /// Consumes `reply` directly — either sends an error synchronously
5524    /// or hands `reply` to the spawned prep task (which routes it back
5525    /// via `CommitAddTorrent`).
5526    async fn dispatch_add_torrent_m170(
5527        &mut self,
5528        params: AddTorrentParams,
5529        reply: oneshot::Sender<crate::Result<Id20>>,
5530    ) {
5531        // Resolve download_dir + prepare category label early so that
5532        // an unknown category fails fast (before any parsing work).
5533        let (resolved_dir, resolved_category) =
5534            match self.resolve_download_dir_and_category(&params) {
5535                Ok(x) => x,
5536                Err(e) => {
5537                    let _ = reply.send(Err(e));
5538                    return;
5539                }
5540            };
5541
5542        let AddTorrentParams {
5543            source,
5544            tags,
5545            paused,
5546            skip_checking: _, // reserved for M171+
5547            ..
5548        } = params;
5549
5550        // M226: resolve None → engine `default_add_paused`. `Some(v)` is an
5551        // explicit per-call override and wins over the engine setting.
5552        let paused = paused.unwrap_or(self.settings.default_add_paused);
5553
5554        match source {
5555            AddSource::Magnet(uri) => {
5556                // Magnet path: stays inline (out of M223 scope).
5557                let result: crate::Result<Id20> = async {
5558                    let magnet = irontide_core::Magnet::parse(&uri)?;
5559                    let info_hash = magnet.info_hash();
5560                    self.reject_if_in_deletion_grace(info_hash)?;
5561                    let id = self.handle_add_magnet(magnet, resolved_dir, tags).await?;
5562                    self.apply_post_add_m170(id, resolved_category, paused);
5563                    Ok(id)
5564                }
5565                .await;
5566                let _ = reply.send(result);
5567            }
5568            AddSource::Bytes(bytes) => {
5569                // Bytes path: spawn-per-add.
5570                let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5571                    let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5572                    let info_hash = meta
5573                        .as_v1()
5574                        .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5575                    self.reject_if_in_deletion_grace(info_hash)?;
5576                    if self.torrents.contains_key(&info_hash) {
5577                        return Err(crate::Error::DuplicateTorrent(info_hash));
5578                    }
5579                    if self.torrents.len() >= self.settings.max_torrents {
5580                        return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5581                    }
5582                    Ok(self.build_add_torrent_prep_bundle(
5583                        meta,
5584                        None,
5585                        resolved_dir,
5586                        tags,
5587                        Some(M170PostAdd {
5588                            category: resolved_category,
5589                            paused,
5590                        }),
5591                    ))
5592                })();
5593                match setup {
5594                    Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5595                    Err(e) => {
5596                        let _ = reply.send(Err(e));
5597                    }
5598                }
5599            }
5600        }
5601    }
5602
5603    /// Resolve the effective download directory + the category label to
5604    /// store on the `TorrentConfig`, following the M170 precedence rules.
5605    fn resolve_download_dir_and_category(
5606        &self,
5607        params: &AddTorrentParams,
5608    ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5609        match (&params.download_dir, &params.category) {
5610            (Some(explicit), cat) => {
5611                // Explicit path wins even when a category is set — qBt
5612                // preserves the category label either way.
5613                Ok((Some(explicit.clone()), cat.clone()))
5614            }
5615            (None, Some(name)) => {
5616                let registry = self.category_registry.read();
5617                match registry.get(name) {
5618                    Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5619                    None => Err(crate::Error::CategoryNotFound(name.clone())),
5620                }
5621            }
5622            (None, None) => Ok((None, None)),
5623        }
5624    }
5625
5626    /// Return an error if `info_hash` is currently being removed by a
5627    /// deleteFiles=true call in another task.
5628    fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5629        if self.deletion_grace.lock().contains(&info_hash) {
5630            return Err(crate::Error::TorrentBeingRemoved(info_hash));
5631        }
5632        Ok(())
5633    }
5634
5635    /// Post-add M170 hooks: stash the category label and mark the torrent
5636    /// paused if requested. Category is recorded for the initial stats
5637    /// snapshot; paused-on-add uses the existing pause path.
5638    fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5639        if let Some(entry) = self.torrents.get(&info_hash) {
5640            // Category label: stored on the torrent handle's config mirror
5641            // so future `stats()` calls include it. Fire-and-forget
5642            // because the field is non-load-bearing for the add path.
5643            if let Some(name) = category {
5644                let handle = entry.handle.clone();
5645                tokio::spawn(async move {
5646                    if let Err(e) = handle.set_category(Some(name)).await {
5647                        warn!(%info_hash, "failed to propagate category: {e}");
5648                    }
5649                });
5650            }
5651            if paused {
5652                let handle = entry.handle.clone();
5653                tokio::spawn(async move {
5654                    if let Err(e) = handle.pause().await {
5655                        warn!(%info_hash, "failed to pause on add: {e}");
5656                    }
5657                });
5658            }
5659        }
5660    }
5661
5662    /// M170: create a category, persist the registry on success.
5663    async fn handle_create_category(
5664        &self,
5665        name: String,
5666        save_path: PathBuf,
5667    ) -> Result<(), crate::category_manager::CategoryError> {
5668        {
5669            let mut registry = self.category_registry.write();
5670            registry.create(name, save_path)?;
5671        }
5672        self.persist_category_registry().await
5673    }
5674
5675    /// M170: edit a category, persist the registry on success.
5676    async fn handle_edit_category(
5677        &self,
5678        name: String,
5679        save_path: PathBuf,
5680    ) -> Result<(), crate::category_manager::CategoryError> {
5681        {
5682            let mut registry = self.category_registry.write();
5683            registry.edit(&name, save_path)?;
5684        }
5685        self.persist_category_registry().await
5686    }
5687
5688    /// M170: remove categories, clear labels on affected torrents, and
5689    /// persist the registry. Returns the names that were actually
5690    /// removed so the CLI / qBt handler can log or echo them.
5691    async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
5692        let removed: Vec<String> = {
5693            let mut registry = self.category_registry.write();
5694            registry.remove(&names)
5695        };
5696        if removed.is_empty() {
5697            return removed;
5698        }
5699
5700        // Clear the `category` label on every torrent assigned to a
5701        // removed name. Fire-and-forget per torrent — failure only
5702        // costs us label-sync (not data).
5703        for entry in self.torrents.values() {
5704            let handle = entry.handle.clone();
5705            let to_check: Vec<String> = removed.clone();
5706            tokio::spawn(async move {
5707                if let Ok(stats) = handle.stats().await
5708                    && let Some(current) = stats.category
5709                    && to_check.iter().any(|n| n.as_str() == current.as_str())
5710                    && let Err(e) = handle.set_category(None).await
5711                {
5712                    warn!(
5713                        cat = %current,
5714                        "failed to clear category label after removeCategories: {e}"
5715                    );
5716                }
5717            });
5718        }
5719
5720        if let Err(e) = self.persist_category_registry().await {
5721            warn!("failed to persist category registry after remove: {e}");
5722        }
5723        removed
5724    }
5725
5726    /// Spawn a blocking task to persist the registry to disk.
5727    async fn persist_category_registry(
5728        &self,
5729    ) -> Result<(), crate::category_manager::CategoryError> {
5730        let registry = Arc::clone(&self.category_registry);
5731        // Clone the current registry state out of the lock to avoid
5732        // holding it across the spawn_blocking boundary.
5733        let snapshot = registry.read().clone();
5734        tokio::task::spawn_blocking(move || snapshot.save())
5735            .await
5736            .map_err(|join_err| {
5737                crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
5738                    "category registry save join error: {join_err}"
5739                )))
5740            })?
5741    }
5742
5743    /// M171: delete a batch of tags. Returns the subset of names that
5744    /// were actually present at call time (unknown names are silently
5745    /// ignored, matching qBt `deleteTags`).
5746    ///
5747    /// After removal, any torrent carrying a deleted tag has that tag
5748    /// pruned from its label set via `TorrentHandle::set_tags`.
5749    async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
5750        let removed = {
5751            let mut reg = self.tag_registry.write();
5752            reg.delete(&names)
5753        };
5754        if !removed.is_empty() {
5755            let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
5756            for entry in self.torrents.values() {
5757                let handle = entry.handle.clone();
5758                let to_remove = to_remove.clone();
5759                tokio::spawn(async move {
5760                    if let Ok(stats) = handle.stats().await {
5761                        let new_tags: Vec<String> = stats
5762                            .tags
5763                            .into_iter()
5764                            .filter(|t| !to_remove.contains(t))
5765                            .collect();
5766                        if let Err(e) = handle.set_tags(new_tags).await {
5767                            tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
5768                        }
5769                    }
5770                });
5771            }
5772            if let Err(e) = self.persist_tag_registry().await {
5773                tracing::warn!(error = %e, "persist tag registry after DeleteTags");
5774            }
5775        }
5776        removed
5777    }
5778
5779    /// M171: add the given tags to each torrent in `info_hashes`. The
5780    /// engine-layer command is a wholesale replacement, so this reads
5781    /// the current tag set for each torrent, unions in the requested
5782    /// tags (sorted + deduped), and replays the result via
5783    /// `TorrentHandle::set_tags`. Unknown info hashes are silently
5784    /// skipped — qBt's `addTags` behaviour.
5785    ///
5786    /// # Errors
5787    ///
5788    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
5789    /// stopped while the batch was in flight.
5790    async fn handle_add_tags_to_torrents(
5791        &self,
5792        info_hashes: Vec<Id20>,
5793        tags_to_add: Vec<String>,
5794    ) -> crate::Result<()> {
5795        for hash in info_hashes {
5796            let Some(entry) = self.torrents.get(&hash) else {
5797                continue;
5798            };
5799            let current = entry.handle.stats().await?;
5800            let mut new_tags = current.tags;
5801            for t in &tags_to_add {
5802                if !new_tags.contains(t) {
5803                    new_tags.push(t.clone());
5804                }
5805            }
5806            new_tags.sort();
5807            new_tags.dedup();
5808            entry.handle.set_tags(new_tags).await?;
5809        }
5810        Ok(())
5811    }
5812
5813    /// M171: remove the given tags from each torrent in `info_hashes`.
5814    /// Unknown info hashes are silently skipped — qBt's `removeTags`
5815    /// behaviour.
5816    ///
5817    /// # Errors
5818    ///
5819    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
5820    /// stopped while the batch was in flight.
5821    async fn handle_remove_tags_from_torrents(
5822        &self,
5823        info_hashes: Vec<Id20>,
5824        tags_to_remove: Vec<String>,
5825    ) -> crate::Result<()> {
5826        for hash in info_hashes {
5827            let Some(entry) = self.torrents.get(&hash) else {
5828                continue;
5829            };
5830            let current = entry.handle.stats().await?;
5831            let new_tags: Vec<String> = current
5832                .tags
5833                .into_iter()
5834                .filter(|t| !tags_to_remove.contains(t))
5835                .collect();
5836            entry.handle.set_tags(new_tags).await?;
5837        }
5838        Ok(())
5839    }
5840
5841    /// M171: spawn a blocking task to persist the tag registry to disk.
5842    /// Mirrors `persist_category_registry`.
5843    async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
5844        let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
5845        tokio::task::spawn_blocking(move || to_save.save())
5846            .await
5847            .unwrap_or_else(|_| {
5848                Err(crate::tag_manager::TagError::Persistence(
5849                    std::io::Error::other("spawn_blocking failed"),
5850                ))
5851            })
5852    }
5853
5854    /// M170: remove a torrent and delete its files from disk.
5855    async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
5856        // v0.173.1: query the TorrentActor for metadata instead of reading
5857        // the deleted `TorrentEntry.meta` cache. For magnet torrents with
5858        // resolved metadata this returns the real file list (fixing the
5859        // v0.173.0 silent-no-op-on-disk bug). For magnets *still resolving*
5860        // we treat it as an empty file list and still proceed with the
5861        // session-level removal — matches the pre-v0.173.1 observable
5862        // behaviour that the *arr e2e test relies on (`deleteFiles=true` on
5863        // a pre-metadata magnet leaves `download_dir` untouched but cleanly
5864        // removes the torrent from the registry).
5865        let handle = {
5866            let entry = self
5867                .torrents
5868                .get(&info_hash)
5869                .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5870            entry.handle.clone()
5871        };
5872        let file_paths: Vec<PathBuf> = match handle.get_meta().await {
5873            Ok(Some(meta)) => meta
5874                .info
5875                .files()
5876                .iter()
5877                .map(|f| f.path.iter().collect::<PathBuf>())
5878                .collect(),
5879            // Meta not yet resolved (pre-metadata magnet) or actor shut down:
5880            // nothing to walk on disk. Session-level removal still proceeds.
5881            Ok(None) | Err(_) => Vec::new(),
5882        };
5883        let download_dir = self.settings.download_dir.clone();
5884        let _ = handle.pause().await;
5885
5886        // Enter the deletion grace window BEFORE dropping the in-memory
5887        // entry — any add that races in during the walk will see the
5888        // grace set and get 409'd.
5889        self.deletion_grace.lock().insert(info_hash);
5890
5891        // Remove from session (same as the existing path — closes
5892        // storage handles, deletes resume file, etc.).
5893        let remove_result = self.handle_remove_torrent(info_hash).await;
5894        if let Err(e) = &remove_result {
5895            warn!(
5896                %info_hash,
5897                error = %e,
5898                "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
5899            );
5900        }
5901
5902        // Now blast the files. `download_dir` is the session default (good
5903        // enough for M170 because add_torrent threads the same dir through
5904        // to storage). Once per-torrent save path is recorded (future
5905        // milestone), swap this out.
5906        let grace = Arc::clone(&self.deletion_grace);
5907        tokio::task::spawn_blocking(move || {
5908            irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
5909            grace.lock().remove(&info_hash);
5910        });
5911
5912        Ok(())
5913    }
5914
5915    async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5916        let entry = self
5917            .torrents
5918            .get(&info_hash)
5919            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5920        entry.handle.pause().await
5921    }
5922
5923    async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5924        let entry = self
5925            .torrents
5926            .get(&info_hash)
5927            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5928        entry.handle.resume().await
5929    }
5930
5931    async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5932        let entry = self
5933            .torrents
5934            .get(&info_hash)
5935            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5936        entry
5937            .handle
5938            .cmd_tx
5939            .send(crate::types::TorrentCommand::ForceResume)
5940            .await
5941            .map_err(|_| crate::Error::Shutdown)
5942    }
5943
5944    async fn handle_set_torrent_seed_ratio(
5945        &self,
5946        info_hash: Id20,
5947        limit: Option<f64>,
5948    ) -> crate::Result<()> {
5949        let entry = self
5950            .torrents
5951            .get(&info_hash)
5952            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5953        let (tx, rx) = oneshot::channel();
5954        entry
5955            .handle
5956            .cmd_tx
5957            .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
5958            .await
5959            .map_err(|_| crate::Error::Shutdown)?;
5960        rx.await.map_err(|_| crate::Error::Shutdown)
5961    }
5962
5963    async fn handle_move_torrent_storage(
5964        &self,
5965        info_hash: Id20,
5966        new_path: std::path::PathBuf,
5967    ) -> crate::Result<()> {
5968        let entry = self
5969            .torrents
5970            .get(&info_hash)
5971            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5972        entry.handle.move_storage(new_path).await
5973    }
5974
5975    async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
5976        let entry = self
5977            .torrents
5978            .get(&info_hash)
5979            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5980        let mut stats = entry.handle.stats().await?;
5981        // Enrich with session-level data that the torrent actor doesn't own.
5982        stats.queue_position = entry.queue_position;
5983        stats.auto_managed = entry.auto_managed;
5984        Ok(stats)
5985    }
5986
5987    async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
5988        // v0.173.1: queries the TorrentActor (single source of truth) instead
5989        // of reading the deleted `TorrentEntry.meta` cache. For magnet torrents
5990        // this returns the real meta once assembled, where v0.173.0 always saw
5991        // `None` → 404 on `/api/v2/torrents/files`.
5992        let meta = self.get_entry_meta(info_hash).await?;
5993        let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
5994            file_list
5995                .iter()
5996                .map(|f| FileInfo {
5997                    path: f.path.iter().collect::<PathBuf>(),
5998                    length: f.length,
5999                })
6000                .collect()
6001        } else {
6002            vec![FileInfo {
6003                path: PathBuf::from(&meta.info.name),
6004                length: meta.info.total_length(),
6005            }]
6006        };
6007
6008        Ok(TorrentInfo {
6009            info_hash,
6010            name: meta.info.name.clone(),
6011            total_length: meta.info.total_length(),
6012            piece_length: meta.info.piece_length,
6013            num_pieces: meta.info.num_pieces() as u32,
6014            files,
6015            private: meta.info.private == Some(1),
6016        })
6017    }
6018
6019    /// Update gauge metrics that come from session-level state.
6020    fn update_session_gauges(&self) {
6021        use crate::stats::{
6022            DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6023            SES_NUM_TORRENTS,
6024        };
6025        let c = &self.counters;
6026        c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6027        c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6028
6029        // DHT presence (instance count, not routing table size)
6030        let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6031        c.set(DHT_NODES, dht_nodes);
6032        c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6033        c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6034
6035        // Ban count
6036        let ban_count = self.ban_manager.read().banned_list().len() as i64;
6037        c.set(PEER_NUM_BANNED, ban_count);
6038    }
6039
6040    /// Snapshot counters and fire a `SessionStatsAlert`.
6041    fn fire_stats_alert(&self) {
6042        self.update_session_gauges();
6043        let values = self.counters.snapshot();
6044        crate::alert::post_alert(
6045            &self.alert_tx,
6046            &self.alert_mask,
6047            crate::alert::AlertKind::SessionStatsAlert { values },
6048        );
6049    }
6050
6051    /// Fire a periodic BEP 51 `sample_infohashes` query to the DHT (M111).
6052    async fn fire_sample_infohashes(&self) {
6053        let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6054            return;
6055        };
6056        let mut buf = [0u8; 20];
6057        irontide_core::random_bytes(&mut buf);
6058        let target = Id20::from(buf);
6059        match dht.sample_infohashes(target).await {
6060            Ok(result) => {
6061                post_alert(
6062                    &self.alert_tx,
6063                    &self.alert_mask,
6064                    AlertKind::DhtSampleInfohashes {
6065                        num_samples: result.samples.len(),
6066                        total_estimate: result.num,
6067                    },
6068                );
6069            }
6070            Err(e) => {
6071                debug!("sample_infohashes failed: {e}");
6072            }
6073        }
6074    }
6075
6076    /// M245 C1 — fan out `stats()` to every torrent ONCE (bounded per-torrent),
6077    /// returning the `(info_hash, stats)` pairs that answered within budget.
6078    ///
6079    /// L2 (M241): issue every per-torrent `stats()` request up front and collect
6080    /// replies as they arrive, instead of awaiting each sequentially (audit
6081    /// Tier-2 L2 — head-of-line blocking on the recv loop). Each request is
6082    /// bounded by a 500 ms timeout mirroring `make_debug_state` (eng-review F5):
6083    /// this runs ON the recv loop, so without a bound a single wedged
6084    /// `TorrentActor` would freeze all session commands indefinitely. A torrent
6085    /// that times out contributes nothing to THIS sample (a rare transient
6086    /// counter dip — the same trade-off `make_debug_state` already makes) rather
6087    /// than hanging the whole engine.
6088    ///
6089    /// The single fan-out feeds BOTH the [`SessionStats`] sums and the published
6090    /// [`SessionSnapshot`] refresh in [`make_session_stats`](Self::make_session_stats)
6091    /// — there is no second per-torrent round-trip per tick.
6092    async fn collect_torrent_stats(&self) -> Vec<(Id20, TorrentStats)> {
6093        use futures::stream::{FuturesUnordered, StreamExt};
6094
6095        let mut futs: FuturesUnordered<_> = self
6096            .torrents
6097            .iter()
6098            .map(|(&info_hash, entry)| {
6099                let handle = entry.handle.clone();
6100                async move {
6101                    tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats())
6102                        .await
6103                        .ok()
6104                        .and_then(Result::ok)
6105                        .map(|stats| (info_hash, stats))
6106                }
6107            })
6108            .collect();
6109
6110        let mut out = Vec::with_capacity(self.torrents.len());
6111        while let Some(maybe) = futs.next().await {
6112            if let Some(pair) = maybe {
6113                out.push(pair);
6114            }
6115        }
6116        out
6117    }
6118
6119    /// M245 A1 — eager membership: publish ONE torrent's current summary into
6120    /// the snapshot the instant its add commits, so reads are read-after-write
6121    /// consistent (the torrent is visible immediately, not one stats tick
6122    /// later). One bounded round-trip for THIS torrent only — NOT a fan-out.
6123    /// If the freshly-spawned actor doesn't answer within the budget, the
6124    /// torrent simply appears on the next tick (the snapshot rebuilds its
6125    /// membership from `self.torrents` every tick — see `make_session_stats`).
6126    async fn snapshot_publish_one(&self, info_hash: Id20) {
6127        let handle = match self.torrents.get(&info_hash) {
6128            Some(entry) => entry.handle.clone(),
6129            None => return,
6130        };
6131        if let Ok(Ok(stats)) =
6132            tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats()).await
6133        {
6134            let mut map = self.snapshot.load().as_map().clone();
6135            map.insert(info_hash, TorrentSummary::from(&stats));
6136            self.snapshot
6137                .store(Arc::new(SessionSnapshot::from_map(map)));
6138        }
6139    }
6140
6141    /// M245 A1 — eager membership: drop ONE torrent from the snapshot the
6142    /// instant its removal commits (read-after-write). Pure map edit, no
6143    /// round-trip.
6144    fn snapshot_drop_one(&self, info_hash: Id20) {
6145        let mut map = self.snapshot.load().as_map().clone();
6146        if map.remove(&info_hash).is_some() {
6147            self.snapshot
6148                .store(Arc::new(SessionSnapshot::from_map(map)));
6149        }
6150    }
6151
6152    async fn make_session_stats(&self) -> SessionStats {
6153        self.update_session_gauges();
6154
6155        let active_torrents = self.torrents.len();
6156        let dht_nodes = usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some());
6157
6158        // C1 (M245): ONE fan-out per tick. Sum the session counters AND refresh
6159        // the published snapshot from the SAME (id, stats) pairs — no second
6160        // round-trip.
6161        let collected = self.collect_torrent_stats().await;
6162
6163        let mut total_downloaded = 0u64;
6164        let mut total_uploaded = 0u64;
6165        let mut responded = std::collections::HashMap::with_capacity(collected.len());
6166        for (info_hash, stats) in collected {
6167            total_downloaded = total_downloaded.saturating_add(stats.downloaded);
6168            total_uploaded = total_uploaded.saturating_add(stats.uploaded);
6169            responded.insert(info_hash, stats);
6170        }
6171
6172        // Rebuild the snapshot keyed by the LIVE membership (`self.torrents` is
6173        // the source of truth — this self-heals any eager-hook drift). A torrent
6174        // that answered gets a fresh summary; one that timed out THIS tick
6175        // carries forward its previous summary (eventually-consistent to one
6176        // tick — the D2 contract) rather than vanishing from reads.
6177        let prev = self.snapshot.load();
6178        let mut map = std::collections::BTreeMap::new();
6179        for &info_hash in self.torrents.keys() {
6180            if let Some(stats) = responded.get(&info_hash) {
6181                map.insert(info_hash, TorrentSummary::from(stats));
6182            } else if let Some(prev_summary) = prev.as_map().get(&info_hash) {
6183                map.insert(info_hash, prev_summary.clone());
6184            }
6185        }
6186        self.snapshot
6187            .store(Arc::new(SessionSnapshot::from_map(map)));
6188
6189        SessionStats {
6190            active_torrents,
6191            total_downloaded,
6192            total_uploaded,
6193            dht_nodes,
6194        }
6195    }
6196
6197    /// Build a debug state snapshot across all torrents, collecting per-torrent
6198    /// stats and per-peer details. Torrents that time out are skipped so a
6199    /// single slow actor never blocks the whole response.
6200    async fn make_debug_state(&self) -> crate::types::DebugState {
6201        use crate::stats::{
6202            DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6203            DISPATCH_NOTIFY_WAKEUP_TOTAL,
6204        };
6205
6206        // Session-wide dispatch counters from the atomic counters array.
6207        let snap = self.counters.snapshot();
6208        let dispatch = crate::types::DebugDispatchState {
6209            acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6210            acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6211            acquire_us: snap[DISPATCH_ACQUIRE_US],
6212            notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6213            pieces_queued: 0,
6214            pieces_inflight: 0,
6215        };
6216
6217        let mut torrents = Vec::with_capacity(self.torrents.len());
6218        for (&info_hash, entry) in &self.torrents {
6219            // Per-torrent stats — skip if the actor is slow.
6220            let Ok(Ok(stats)) =
6221                tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6222                    .await
6223            else {
6224                continue;
6225            };
6226
6227            // Per-peer details — skip on timeout.
6228            let peers_raw = match tokio::time::timeout(
6229                std::time::Duration::from_millis(500),
6230                entry.handle.get_peer_info(),
6231            )
6232            .await
6233            {
6234                Ok(Ok(p)) => p,
6235                _ => Vec::new(),
6236            };
6237
6238            let peers: Vec<crate::types::DebugPeerState> = peers_raw
6239                .iter()
6240                .map(|p| crate::types::DebugPeerState {
6241                    addr: p.addr,
6242                    in_flight: p.in_flight_requests,
6243                    target_depth: p.target_pipeline_depth,
6244                    choking: p.peer_choking,
6245                    download_rate: p.download_rate,
6246                })
6247                .collect();
6248
6249            let mut per_torrent_dispatch = dispatch.clone();
6250            per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6251            per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6252
6253            torrents.push(crate::types::DebugTorrentState {
6254                info_hash: info_hash.to_hex(),
6255                state: format!("{:?}", stats.state),
6256                num_peers: stats.peers_connected,
6257                dispatch: per_torrent_dispatch,
6258                peers,
6259            });
6260        }
6261
6262        crate::types::DebugState { torrents }
6263    }
6264
6265    async fn handle_save_torrent_resume(
6266        &self,
6267        info_hash: Id20,
6268    ) -> crate::Result<irontide_core::FastResumeData> {
6269        let entry = self
6270            .torrents
6271            .get(&info_hash)
6272            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6273        let mut resume = entry.handle.save_resume_data().await?;
6274        // Patch in queue state from SessionActor's TorrentEntry (the
6275        // TorrentHandle doesn't know about queue position / auto-managed).
6276        resume.queue_position = i64::from(entry.queue_position);
6277        resume.auto_managed = i64::from(entry.auto_managed);
6278        Ok(resume)
6279    }
6280
6281    async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6282        use crate::persistence::SessionState;
6283
6284        let mut torrents = Vec::new();
6285        for (info_hash, entry) in &self.torrents {
6286            match entry.handle.save_resume_data().await {
6287                Ok(rd) => torrents.push(rd),
6288                Err(e) => {
6289                    warn!(%info_hash, "failed to save resume data: {e}");
6290                }
6291            }
6292        }
6293
6294        // Serialize smart ban state (scoped to drop RwLockReadGuard before awaits)
6295        let (banned_peers, peer_strikes) = {
6296            let ban_mgr = self.ban_manager.read();
6297            let banned_peers: Vec<String> = ban_mgr
6298                .banned_list()
6299                .iter()
6300                .map(std::string::ToString::to_string)
6301                .collect();
6302            let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6303                .strikes_map()
6304                .iter()
6305                .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6306                    ip: ip.to_string(),
6307                    count: i64::from(count),
6308                })
6309                .collect();
6310            (banned_peers, peer_strikes)
6311        };
6312
6313        let mut dht_entries = Vec::new();
6314        let mut dht_node_id = None;
6315        if let Some(ref dht) = self.dht_v4 {
6316            // Save the (possibly BEP 42-regenerated) node ID for next session
6317            if let Ok(stats) = dht.stats().await {
6318                dht_node_id = Some(stats.node_id.to_hex());
6319            }
6320            for (_id, addr) in dht.get_routing_nodes().await {
6321                dht_entries.push(crate::persistence::DhtNodeEntry {
6322                    host: addr.ip().to_string(),
6323                    port: i64::from(addr.port()),
6324                });
6325            }
6326        }
6327        if let Some(ref dht) = self.dht_v6 {
6328            for (_id, addr) in dht.get_routing_nodes().await {
6329                dht_entries.push(crate::persistence::DhtNodeEntry {
6330                    host: addr.ip().to_string(),
6331                    port: i64::from(addr.port()),
6332                });
6333            }
6334        }
6335
6336        Ok(SessionState {
6337            dht_nodes: dht_entries,
6338            dht_node_id,
6339            torrents,
6340            banned_peers,
6341            peer_strikes,
6342        })
6343    }
6344
6345    /// Compute the effective resume data directory from settings.
6346    fn effective_resume_dir(&self) -> PathBuf {
6347        self.settings
6348            .resume_data_dir
6349            .clone()
6350            .unwrap_or_else(crate::resume_file::default_resume_dir)
6351    }
6352
6353    /// Load and restore torrents from per-torrent resume files on disk.
6354    ///
6355    /// Scans the resume directory, deserializes each `.resume` file, reconstructs
6356    /// the torrent metadata or magnet, adds it to the session, and restores the
6357    /// piece bitmap where available.
6358    async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6359        let resume_dir = self.effective_resume_dir();
6360        let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6361
6362        let mut restored = 0usize;
6363        let mut skipped = 0usize;
6364        let mut failed = 0usize;
6365
6366        for path in &paths {
6367            let file_name = path
6368                .file_name()
6369                .and_then(|n| n.to_str())
6370                .unwrap_or("<unknown>");
6371
6372            // Read and deserialize
6373            let bytes = match std::fs::read(path) {
6374                Ok(b) => b,
6375                Err(e) => {
6376                    warn!(file = %file_name, "failed to read resume file: {e}");
6377                    failed = failed.saturating_add(1);
6378                    continue;
6379                }
6380            };
6381
6382            let rd = match crate::resume_file::deserialize_resume(&bytes) {
6383                Ok(rd) => rd,
6384                Err(e) => {
6385                    warn!(file = %file_name, "failed to deserialize resume file: {e}");
6386                    failed = failed.saturating_add(1);
6387                    continue;
6388                }
6389            };
6390
6391            // Try to reconstruct as a resolved torrent (info dict present).
6392            if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6393                let info_hash = meta.info_hash;
6394                let pieces = rd.pieces.clone();
6395                let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6396
6397                // Restore to the original save_path (per-torrent download dir).
6398                let restore_dir = if rd.save_path.is_empty() {
6399                    None
6400                } else {
6401                    Some(PathBuf::from(&rd.save_path))
6402                };
6403                // M171: restore tags by baking them into the TorrentConfig
6404                // at add-time, matching the `AddTorrentParams::with_tags`
6405                // semantics. Category still goes through the post-add
6406                // fire-and-forget path (M170 behaviour preserved).
6407                let restore_tags = rd.tags.clone();
6408                match self
6409                    .handle_add_torrent(torrent_meta, None, restore_dir, restore_tags)
6410                    .await
6411                {
6412                    Ok(added_hash) => {
6413                        // Restore the piece bitmap if non-empty.
6414                        if !pieces.is_empty()
6415                            && let Some(entry) = self.torrents.get(&added_hash)
6416                            && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6417                        {
6418                            warn!(
6419                                %info_hash,
6420                                "failed to restore piece bitmap, torrent will recheck: {e}"
6421                            );
6422                        }
6423                        // M170: restore the category label if persisted.
6424                        if let Some(ref cat) = rd.category
6425                            && let Some(entry) = self.torrents.get(&added_hash)
6426                        {
6427                            let handle = entry.handle.clone();
6428                            let cat_owned = cat.clone();
6429                            tokio::spawn(async move {
6430                                let _ = handle.set_category(Some(cat_owned)).await;
6431                            });
6432                        }
6433                        // M178: restore per-URL web-seed stats so cumulative
6434                        // bytes and error history survive app restart.
6435                        if !rd.web_seed_stats.is_empty()
6436                            && let Some(entry) = self.torrents.get(&added_hash)
6437                        {
6438                            let handle = entry.handle.clone();
6439                            let stats_owned = rd.web_seed_stats.clone();
6440                            tokio::spawn(async move {
6441                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6442                            });
6443                        }
6444                        if self.settings.queueing_enabled
6445                            && let Some(entry) = self.torrents.get(&added_hash)
6446                        {
6447                            let _ = entry.handle.queue().await;
6448                        }
6449                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6450                            entry.queue_position = rd.queue_position as i32;
6451                            entry.auto_managed = rd.auto_managed != 0;
6452                        }
6453                        info!(%info_hash, "restored torrent from resume file");
6454                        restored = restored.saturating_add(1);
6455                    }
6456                    Err(crate::Error::DuplicateTorrent(_)) => {
6457                        debug!(%info_hash, "skipped duplicate torrent from resume");
6458                        skipped = skipped.saturating_add(1);
6459                    }
6460                    Err(e) => {
6461                        warn!(%info_hash, "failed to add restored torrent: {e}");
6462                        failed = failed.saturating_add(1);
6463                    }
6464                }
6465            } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6466                // Unresolved magnet: re-add as magnet link.
6467                let info_hash = magnet.info_hash();
6468                let restore_dir = if rd.save_path.is_empty() {
6469                    None
6470                } else {
6471                    Some(PathBuf::from(&rd.save_path))
6472                };
6473                // M171: restore tags via the add-time config bake path.
6474                let restore_tags = rd.tags.clone();
6475                match self
6476                    .handle_add_magnet(magnet, restore_dir, restore_tags)
6477                    .await
6478                {
6479                    Ok(added_hash) => {
6480                        // M170: restore category on magnet too.
6481                        if let Some(ref cat) = rd.category
6482                            && let Some(entry) = self.torrents.get(&added_hash)
6483                        {
6484                            let handle = entry.handle.clone();
6485                            let cat_owned = cat.clone();
6486                            tokio::spawn(async move {
6487                                let _ = handle.set_category(Some(cat_owned)).await;
6488                            });
6489                        }
6490                        // M178: restore per-URL web-seed stats on magnet too,
6491                        // so resumes from a magnet-added torrent that already
6492                        // had web-seed activity recover correctly.
6493                        if !rd.web_seed_stats.is_empty()
6494                            && let Some(entry) = self.torrents.get(&added_hash)
6495                        {
6496                            let handle = entry.handle.clone();
6497                            let stats_owned = rd.web_seed_stats.clone();
6498                            tokio::spawn(async move {
6499                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6500                            });
6501                        }
6502                        if self.settings.queueing_enabled
6503                            && let Some(entry) = self.torrents.get(&added_hash)
6504                        {
6505                            let _ = entry.handle.queue().await;
6506                        }
6507                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6508                            entry.queue_position = rd.queue_position as i32;
6509                            entry.auto_managed = rd.auto_managed != 0;
6510                        }
6511                        info!(%info_hash, "restored magnet from resume file");
6512                        restored = restored.saturating_add(1);
6513                    }
6514                    Err(crate::Error::DuplicateTorrent(_)) => {
6515                        debug!(%info_hash, "skipped duplicate magnet from resume");
6516                        skipped = skipped.saturating_add(1);
6517                    }
6518                    Err(e) => {
6519                        warn!(%info_hash, "failed to add restored magnet: {e}");
6520                        failed = failed.saturating_add(1);
6521                    }
6522                }
6523            } else {
6524                warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6525                failed = failed.saturating_add(1);
6526            }
6527        }
6528
6529        // Renormalize queue positions to contiguous 0..N-1. Handles
6530        // duplicate positions from crash mid-save, manual edits, or
6531        // older resume formats that default all positions to 0.
6532        {
6533            let mut entries: Vec<(Id20, i32)> = self
6534                .torrents
6535                .iter()
6536                .filter(|(_, e)| e.auto_managed)
6537                .map(|(h, e)| (*h, e.queue_position))
6538                .collect();
6539            entries.sort_by_key(|&(_, pos)| pos);
6540            for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6541                if let Some(entry) = self.torrents.get_mut(&hash) {
6542                    entry.queue_position = new_pos as i32;
6543                }
6544            }
6545        }
6546
6547        info!(restored, skipped, failed, "resume state loaded");
6548        Ok(ResumeLoadResult {
6549            restored,
6550            skipped,
6551            failed,
6552        })
6553    }
6554
6555    /// Save resume files for all torrents with a dirty `need_save_resume` flag.
6556    ///
6557    /// Returns the number of resume files successfully written.
6558    /// Snapshot the data `run_resume_save_jobs` needs while we hold `&self` on
6559    /// the actor. Cheap — clones channel-sender handles + copies queue metadata.
6560    fn snapshot_resume_jobs(&self) -> (std::path::PathBuf, Vec<ResumeSaveJob>) {
6561        let resume_dir = self.effective_resume_dir();
6562        let jobs = self
6563            .torrents
6564            .iter()
6565            .map(|(info_hash, entry)| ResumeSaveJob {
6566                info_hash: *info_hash,
6567                handle: entry.handle.clone(),
6568                queue_position: i64::from(entry.queue_position),
6569                auto_managed: i64::from(entry.auto_managed),
6570            })
6571            .collect();
6572        (resume_dir, jobs)
6573    }
6574
6575    /// Save resume files for all dirty torrents, inline. Retained for the
6576    /// shutdown path, which MUST await the save to completion before the process
6577    /// exits (terminal — recv-loop liveness no longer matters there). Acquires
6578    /// `resume_save_lock` first so it waits behind any in-flight spawned save
6579    /// rather than racing it onto the same temp path (F4); since shutdown runs
6580    /// this BEFORE draining torrents, the awaited periodic save still completes.
6581    /// The periodic timer + `SaveResumeState` RPC instead spawn
6582    /// `run_resume_save_jobs` while holding the lock, so they never block the loop.
6583    async fn save_dirty_resume_files(&self) -> usize {
6584        let _guard = self.resume_save_lock.lock().await;
6585        let (resume_dir, jobs) = self.snapshot_resume_jobs();
6586        run_resume_save_jobs(resume_dir, jobs).await
6587    }
6588
6589    /// Apply new settings at runtime, transactionally (M173 Lane B, B1).
6590    ///
6591    /// Phases (executed in order; rollback in REVERSE on first failure):
6592    ///
6593    /// 1. Rate limits + alert mask (cheap; rollback restores)
6594    /// 2. Listen-port rebind (B4 wires the real reconfig — B1 stub no-op)
6595    /// 3. DHT enable/disable (B5-B7 wire — B1 stub no-op)
6596    /// 4. LSD enable/disable (B9 wires — B1 stub no-op)
6597    ///
6598    /// On any phase failure, already-applied phases roll back (LIFO) and
6599    /// the caller sees the original [`crate::Error`] that triggered the
6600    /// failure. Until B4-B9 land, phases 2-4 are no-ops, so this method
6601    /// behaves identically to the M171 implementation for callers that
6602    /// only patch rate limits / alert mask. Listen-port / DHT / LSD
6603    /// changes are still classified as "`restart_required`" by
6604    /// [`classify_restart_required`] until B10 graduates them.
6605    ///
6606    /// # Errors
6607    ///
6608    /// Returns [`crate::Error::InvalidSettings`] if `Settings::validate`
6609    /// rejects the new patch. Future variants from B4-B9 will surface
6610    /// listener / DHT / LSD restart failures via [`crate::apply::ApplyError`].
6611    fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
6612        // Validate FIRST so that an invalid patch never even enters the
6613        // transactional pipeline. This matches the M171 behaviour.
6614        new.validate()?;
6615
6616        // Snapshot pre-call values for rollback closures. These are
6617        // captured by-value so the rollback closures can run without
6618        // borrowing `self` (the executor needs `&mut self`).
6619        let old_upload_rate = self.settings.upload_rate_limit;
6620        let old_download_rate = self.settings.download_rate_limit;
6621        let old_alert_mask = self.settings.alert_mask;
6622        let old_settings = self.settings.clone();
6623        let old_settings_for_delta = self.settings.clone();
6624
6625        let new_upload_rate = new.upload_rate_limit;
6626        let new_download_rate = new.download_rate_limit;
6627        let new_alert_mask = new.alert_mask;
6628
6629        // Phase 1: rate limits + alert mask + Settings struct.
6630        // The rollback restores all four mutations (rate buckets +
6631        // mask atomic + Settings struct) — Settings is cloned for
6632        // restoration so the caller sees the original on failure.
6633        let upload_bucket = Arc::clone(&self.global_upload_bucket);
6634        let download_bucket = Arc::clone(&self.global_download_bucket);
6635        let alert_mask = Arc::clone(&self.alert_mask);
6636
6637        let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
6638            name: "rate_limits_and_mask",
6639            forward: Box::new(move |this: &mut Self| {
6640                if new_upload_rate != old_upload_rate {
6641                    upload_bucket.lock().set_rate(new_upload_rate);
6642                }
6643                if new_download_rate != old_download_rate {
6644                    download_bucket.lock().set_rate(new_download_rate);
6645                }
6646                if new_alert_mask != old_alert_mask {
6647                    alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
6648                }
6649                this.settings = new;
6650                Ok(())
6651            }),
6652            rollback: Box::new(move |this: &mut Self| {
6653                // Restore in the reverse order of forward mutations.
6654                this.settings = old_settings;
6655                if new_alert_mask != old_alert_mask {
6656                    this.alert_mask
6657                        .store(old_alert_mask.bits(), Ordering::Relaxed);
6658                }
6659                if new_download_rate != old_download_rate {
6660                    this.global_download_bucket
6661                        .lock()
6662                        .set_rate(old_download_rate);
6663                }
6664                if new_upload_rate != old_upload_rate {
6665                    this.global_upload_bucket.lock().set_rate(old_upload_rate);
6666                }
6667            }),
6668        };
6669
6670        // Phase 2 (listen_port) and Phase 4 (LSD) remain unimplemented.
6671        let phases = vec![phase1];
6672
6673        match crate::apply::apply_phases_with_rollback(self, phases) {
6674            Ok(()) => {
6675                // M226 Step 5: broadcast the new Settings to the
6676                // notification dispatcher. `send` on a watch channel
6677                // returns Err only when every receiver has dropped,
6678                // which means the dispatcher has already exited — at
6679                // that point the toggle is academic and we silently
6680                // discard the error so apply_settings stays infallible
6681                // on the notification axis.
6682                let _ = self.notification_settings_tx.send(self.settings.clone());
6683
6684                // Phase 3: DHT toggle (v0.187.1).
6685                if (old_settings_for_delta.enable_dht != self.settings.enable_dht
6686                    || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
6687                    && (!self.settings.enable_dht || self.settings.anonymous_mode)
6688                {
6689                    tracing::info!("DHT disabled via settings");
6690                    self.dht_v4 = None;
6691                    self.dht_v6 = None;
6692                    self.dht_v4_broadcast.replace(None);
6693                    self.dht_v6_broadcast.replace(None);
6694                }
6695
6696                // M224 D3: keep the listener's cap atomic in sync. The
6697                // listener reads this on every TCP accept, so the new cap
6698                // applies to the next accepted connection without a restart.
6699                // Update is unconditional — `store` is cheap, and skipping
6700                // when unchanged would require an additional read first.
6701                self.max_connections_global.store(
6702                    self.settings.max_connections_global,
6703                    std::sync::atomic::Ordering::SeqCst,
6704                );
6705
6706                let delta =
6707                    crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
6708                if delta.save_resume_interval_secs.is_some() {
6709                    self.resume_save_notify.notify_one();
6710                }
6711                if let Some(enabled) = delta.ip_filter_enabled {
6712                    self.ip_filter.write().enabled = enabled;
6713                }
6714                // M226 Step 6: poke the watched-folder dispatcher to
6715                // rebuild its debouncer against the new path. We only
6716                // fire when the path itself OR the delete-after-add
6717                // flag changed — rate-limit / DHT tweaks must NOT
6718                // churn inotify FDs.
6719                if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
6720                    self.watched_folder_changed.notify_one();
6721                }
6722                if !delta.is_empty() {
6723                    // v0.187.3 / 1A: SettingsDelta fan-out. We use try_send so a
6724                    // saturated per-torrent channel doesn't block the apply call;
6725                    // the trade-off is that the dropped torrent will retain its
6726                    // old config until the next apply. Pre-v0.187.3 this was a
6727                    // silent `let _`; now we log at WARN so partial-failure
6728                    // events are observable without becoming fatal.
6729                    let mut failed: Vec<irontide_core::Id20> = Vec::new();
6730                    for (hash, entry) in &self.torrents {
6731                        if entry
6732                            .handle
6733                            .cmd_tx
6734                            .try_send(crate::types::TorrentCommand::UpdateSettings(delta.clone()))
6735                            .is_err()
6736                        {
6737                            failed.push(*hash);
6738                        }
6739                    }
6740                    if !failed.is_empty() {
6741                        tracing::warn!(
6742                            count = failed.len(),
6743                            "SettingsDelta fan-out: per-torrent channel saturated; \
6744                             affected torrents will pick up the change on the next apply"
6745                        );
6746                    }
6747                }
6748                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
6749                Ok(())
6750            }
6751            Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
6752                Err(crate::Error::InvalidSettings(msg))
6753            }
6754            Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
6755        }
6756    }
6757
6758    /// Build a `QueueEntry` snapshot from current auto-managed torrents.
6759    fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
6760        self.torrents
6761            .iter()
6762            .filter(|(_, e)| e.auto_managed)
6763            .map(|(&hash, e)| crate::queue::QueueEntry {
6764                info_hash: hash,
6765                position: e.queue_position,
6766            })
6767            .collect()
6768    }
6769
6770    fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
6771        if !self.torrents.contains_key(&info_hash) {
6772            return Err(crate::Error::TorrentNotFound(info_hash));
6773        }
6774        let mut entries = self.queue_entries();
6775        let changed = crate::queue::set_position(&mut entries, info_hash, pos);
6776        self.apply_queue_changes(&changed);
6777        Ok(())
6778    }
6779
6780    fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
6781        if !self.torrents.contains_key(&info_hash) {
6782            return Err(crate::Error::TorrentNotFound(info_hash));
6783        }
6784        let mut entries = self.queue_entries();
6785        let changed = op(&mut entries, info_hash);
6786        self.apply_queue_changes(&changed);
6787        Ok(())
6788    }
6789
6790    /// Apply position changes back to `TorrentEntry` fields and fire alerts.
6791    fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
6792        for &(hash, old_pos, new_pos) in changed {
6793            if let Some(entry) = self.torrents.get_mut(&hash) {
6794                entry.queue_position = new_pos;
6795            }
6796            crate::alert::post_alert(
6797                &self.alert_tx,
6798                &self.alert_mask,
6799                crate::alert::AlertKind::TorrentQueuePositionChanged {
6800                    info_hash: hash,
6801                    old_pos,
6802                    new_pos,
6803                },
6804            );
6805        }
6806    }
6807
6808    async fn evaluate_queue(&mut self) {
6809        if !self.settings.queueing_enabled {
6810            return;
6811        }
6812        let now = tokio::time::Instant::now();
6813        let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
6814        let mut candidates = Vec::new();
6815
6816        // Collect info hashes first to avoid borrow issues with async calls
6817        let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6818
6819        for &info_hash in &hashes {
6820            let (queue_position, started_at) = {
6821                let Some(entry) = self.torrents.get(&info_hash) else {
6822                    continue;
6823                };
6824                if !entry.auto_managed {
6825                    continue;
6826                }
6827                (entry.queue_position, entry.started_at)
6828            };
6829
6830            // Get current stats (async call — self.torrents is not borrowed here)
6831            let stats = match self.torrents.get(&info_hash) {
6832                Some(entry) => match entry.handle.stats().await {
6833                    Ok(s) => s,
6834                    Err(_) => continue,
6835                },
6836                None => continue,
6837            };
6838
6839            let category = match stats.state {
6840                TorrentState::Checking | TorrentState::FetchingMetadata => {
6841                    crate::queue::QueueCategory::Checking
6842                }
6843                TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
6844                TorrentState::Seeding | TorrentState::Complete => {
6845                    crate::queue::QueueCategory::Seeding
6846                }
6847                TorrentState::Queued => {
6848                    if stats.progress >= 1.0 {
6849                        crate::queue::QueueCategory::Seeding
6850                    } else {
6851                        crate::queue::QueueCategory::Downloading
6852                    }
6853                }
6854                TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
6855            };
6856
6857            let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
6858
6859            // EWMA-smooth the rates for stable inactive classification.
6860            let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
6861            let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
6862            {
6863                let raw_dl = stats.download_rate as f64;
6864                let raw_ul = stats.upload_rate as f64;
6865                entry.smoothed_download_rate =
6866                    alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
6867                entry.smoothed_upload_rate =
6868                    alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
6869                (entry.smoothed_download_rate, entry.smoothed_upload_rate)
6870            } else {
6871                continue;
6872            };
6873
6874            let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
6875
6876            let is_inactive = past_startup
6877                && match category {
6878                    crate::queue::QueueCategory::Downloading => {
6879                        (smoothed_dl as u64) < self.settings.inactive_down_rate
6880                    }
6881                    crate::queue::QueueCategory::Seeding => {
6882                        (smoothed_ul as u64) < self.settings.inactive_up_rate
6883                    }
6884                    crate::queue::QueueCategory::Checking => false,
6885                };
6886
6887            let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
6888                std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
6889            } else {
6890                startup_duration
6891            };
6892            let recently_started =
6893                started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
6894
6895            let seed_rank = if category == crate::queue::QueueCategory::Seeding {
6896                Some(crate::queue::compute_seed_rank(
6897                    stats.num_complete,
6898                    stats.num_incomplete,
6899                ))
6900            } else {
6901                None
6902            };
6903
6904            candidates.push(crate::queue::QueueCandidate {
6905                info_hash,
6906                position: queue_position,
6907                category,
6908                is_active,
6909                is_inactive,
6910                recently_started,
6911                seed_rank,
6912            });
6913        }
6914
6915        let config = crate::queue::QueueConfig {
6916            active_downloads: self.settings.active_downloads,
6917            active_seeds: self.settings.active_seeds,
6918            active_checking: self.settings.active_checking,
6919            active_limit: self.settings.active_limit,
6920            dont_count_slow: self.settings.dont_count_slow_torrents,
6921            prefer_seeds: self.settings.auto_manage_prefer_seeds,
6922        };
6923        let mut decision = crate::queue::evaluate(&candidates, &config);
6924        crate::queue::apply_preemption(&mut decision, &candidates);
6925
6926        // Apply decisions
6927        for hash in &decision.to_pause {
6928            if let Some(entry) = self.torrents.get(hash) {
6929                let _ = entry.handle.queue().await;
6930            }
6931            post_alert(
6932                &self.alert_tx,
6933                &self.alert_mask,
6934                AlertKind::TorrentAutoManaged {
6935                    info_hash: *hash,
6936                    paused: true,
6937                },
6938            );
6939        }
6940
6941        for hash in &decision.to_resume {
6942            if let Some(entry) = self.torrents.get_mut(hash) {
6943                let _ = entry.handle.resume().await;
6944                entry.started_at = Some(tokio::time::Instant::now());
6945            }
6946            post_alert(
6947                &self.alert_tx,
6948                &self.alert_mask,
6949                AlertKind::TorrentAutoManaged {
6950                    info_hash: *hash,
6951                    paused: false,
6952                },
6953            );
6954        }
6955    }
6956
6957    /// Handle a pre-validated inbound connection from the `ListenerTask` (M114).
6958    fn handle_identified_inbound(&self, conn: crate::listener::IdentifiedConnection) {
6959        if let Some(entry) = self.torrents.get(&conn.info_hash) {
6960            debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
6961            let handle = entry.handle.clone();
6962            tokio::spawn(async move {
6963                let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
6964            });
6965        } else {
6966            // Race: torrent removed between validation and receipt.
6967            debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
6968        }
6969    }
6970
6971    /// Handle an incoming SSL/TLS connection (M42).
6972    ///
6973    /// Uses `LazyConfigAcceptor` to peek at the TLS `ClientHello` and extract
6974    /// the SNI (hex-encoded info hash) to route the connection to the right
6975    /// torrent. The full TLS handshake uses the torrent's CA cert to build
6976    /// the server config.
6977    async fn handle_ssl_incoming(
6978        &mut self,
6979        stream: crate::transport::BoxedStream,
6980        addr: std::net::SocketAddr,
6981    ) {
6982        use tokio_rustls::LazyConfigAcceptor;
6983
6984        let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
6985
6986        let start_handshake = match acceptor.await {
6987            Ok(sh) => sh,
6988            Err(e) => {
6989                debug!(%addr, error = %e, "SSL ClientHello read failed");
6990                return;
6991            }
6992        };
6993
6994        // Extract SNI from ClientHello
6995        let client_hello = start_handshake.client_hello();
6996        let sni = if let Some(name) = client_hello.server_name() {
6997            name.to_string()
6998        } else {
6999            debug!(%addr, "SSL connection missing SNI");
7000            return;
7001        };
7002
7003        // SNI is hex-encoded info hash (40 chars for SHA-1)
7004        let Ok(info_hash) = Id20::from_hex(&sni) else {
7005            debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
7006            return;
7007        };
7008
7009        // Look up the torrent
7010        let Some(torrent) = self.torrents.get(&info_hash) else {
7011            debug!(%addr, %info_hash, "SSL connection for unknown torrent");
7012            return;
7013        };
7014
7015        // Get the SSL CA cert from the torrent's metadata.
7016        //
7017        // v0.173.1: `TorrentEntry.meta` was deleted. Query the TorrentActor
7018        // directly (single source of truth). Magnet torrents previously hit
7019        // the "non-SSL torrent" branch here because their entry.meta was
7020        // always None — BEP 4A handshakes silently dropped.
7021        let meta = match torrent.handle.get_meta().await {
7022            Ok(Some(m)) => m,
7023            Ok(None) => {
7024                debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
7025                return;
7026            }
7027            Err(_) => {
7028                debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
7029                return;
7030            }
7031        };
7032        let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
7033            cert.clone()
7034        } else {
7035            debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
7036            return;
7037        };
7038
7039        // Build server config using the torrent's CA cert
7040        let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
7041            match mgr.server_config(&ssl_cert) {
7042                Ok(cfg) => cfg,
7043                Err(e) => {
7044                    warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
7045                    return;
7046                }
7047            }
7048        } else {
7049            debug!(%addr, "SSL manager not initialized");
7050            return;
7051        };
7052
7053        // Complete the TLS handshake
7054        let tls_stream = match start_handshake.into_stream(server_config).await {
7055            Ok(s) => s,
7056            Err(e) => {
7057                warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
7058                post_alert(
7059                    &self.alert_tx,
7060                    &self.alert_mask,
7061                    AlertKind::SslTorrentError {
7062                        info_hash,
7063                        message: format!("inbound TLS handshake from {addr}: {e}"),
7064                    },
7065                );
7066                return;
7067            }
7068        };
7069
7070        // Route to the torrent actor via SpawnSslPeer command
7071        let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7072    }
7073
7074    async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7075        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7076        match dht.put_immutable(value.clone()).await {
7077            Ok(target) => {
7078                post_alert(
7079                    &self.alert_tx,
7080                    &self.alert_mask,
7081                    AlertKind::DhtPutComplete { target },
7082                );
7083                Ok(target)
7084            }
7085            Err(e) => {
7086                let target = irontide_core::sha1(&value);
7087                post_alert(
7088                    &self.alert_tx,
7089                    &self.alert_mask,
7090                    AlertKind::DhtItemError {
7091                        target,
7092                        message: e.to_string(),
7093                    },
7094                );
7095                Err(crate::Error::Dht(e))
7096            }
7097        }
7098    }
7099
7100    async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7101        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7102        match dht.get_immutable(target).await {
7103            Ok(value) => {
7104                post_alert(
7105                    &self.alert_tx,
7106                    &self.alert_mask,
7107                    AlertKind::DhtGetResult {
7108                        target,
7109                        value: value.clone(),
7110                    },
7111                );
7112                Ok(value)
7113            }
7114            Err(e) => {
7115                post_alert(
7116                    &self.alert_tx,
7117                    &self.alert_mask,
7118                    AlertKind::DhtItemError {
7119                        target,
7120                        message: e.to_string(),
7121                    },
7122                );
7123                Err(crate::Error::Dht(e))
7124            }
7125        }
7126    }
7127
7128    async fn handle_dht_put_mutable(
7129        &self,
7130        keypair_bytes: [u8; 32],
7131        value: Vec<u8>,
7132        seq: i64,
7133        salt: Vec<u8>,
7134    ) -> crate::Result<Id20> {
7135        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7136        match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7137            Ok(target) => {
7138                post_alert(
7139                    &self.alert_tx,
7140                    &self.alert_mask,
7141                    AlertKind::DhtMutablePutComplete { target, seq },
7142                );
7143                Ok(target)
7144            }
7145            Err(e) => {
7146                post_alert(
7147                    &self.alert_tx,
7148                    &self.alert_mask,
7149                    AlertKind::DhtItemError {
7150                        target: Id20::from([0u8; 20]),
7151                        message: e.to_string(),
7152                    },
7153                );
7154                Err(crate::Error::Dht(e))
7155            }
7156        }
7157    }
7158
7159    async fn handle_dht_get_mutable(
7160        &self,
7161        public_key: [u8; 32],
7162        salt: Vec<u8>,
7163    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7164        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7165        let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7166        match dht.get_mutable(public_key, salt).await {
7167            Ok(result) => {
7168                let (value, seq) = match &result {
7169                    Some((v, s)) => (Some(v.clone()), Some(*s)),
7170                    None => (None, None),
7171                };
7172                post_alert(
7173                    &self.alert_tx,
7174                    &self.alert_mask,
7175                    AlertKind::DhtMutableGetResult {
7176                        target,
7177                        value,
7178                        seq,
7179                        public_key,
7180                    },
7181                );
7182                Ok(result)
7183            }
7184            Err(e) => {
7185                post_alert(
7186                    &self.alert_tx,
7187                    &self.alert_mask,
7188                    AlertKind::DhtItemError {
7189                        target,
7190                        message: e.to_string(),
7191                    },
7192                );
7193                Err(crate::Error::Dht(e))
7194            }
7195        }
7196    }
7197
7198    async fn shutdown_all(&mut self) {
7199        // Save resume files before draining torrents (M161 Phase 5).
7200        let save_count = self.save_dirty_resume_files().await;
7201        if save_count > 0 {
7202            info!(save_count, "saved resume files on shutdown");
7203        }
7204
7205        for (info_hash, entry) in self.torrents.drain() {
7206            debug!(%info_hash, "shutting down torrent");
7207            let _ = entry.handle.shutdown().await;
7208        }
7209        if let Some(ref dht) = self.dht_v4 {
7210            let _ = dht.shutdown().await;
7211        }
7212        if let Some(ref dht) = self.dht_v6 {
7213            let _ = dht.shutdown().await;
7214        }
7215        if let Some(ref nat) = self.nat {
7216            nat.shutdown().await;
7217        }
7218        if let Some(ref lsd) = self.lsd {
7219            lsd.shutdown().await;
7220        }
7221        if let Some(ref socket) = self.utp_socket
7222            && let Err(e) = socket.shutdown().await
7223        {
7224            debug!(error = %e, "uTP socket shutdown error");
7225        }
7226        if let Some(ref socket) = self.utp_socket_v6
7227            && let Err(e) = socket.shutdown().await
7228        {
7229            debug!(error = %e, "uTP v6 socket shutdown error");
7230        }
7231        self.disk_manager.shutdown().await;
7232    }
7233}
7234
7235/// Helper to receive NAT events from an optional receiver.
7236/// Returns `pending` if no receiver is available, so the `select!` branch is skipped.
7237async fn recv_nat_event(
7238    rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7239) -> irontide_nat::NatEvent {
7240    match rx {
7241        Some(r) => match r.recv().await {
7242            Some(event) => event,
7243            None => std::future::pending().await,
7244        },
7245        None => std::future::pending().await,
7246    }
7247}
7248
7249/// Receive from an optional DHT IP consensus channel, pending forever if absent.
7250async fn recv_dht_ip(
7251    rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7252) -> Option<std::net::IpAddr> {
7253    match rx {
7254        Some(r) => r.recv().await,
7255        None => std::future::pending().await,
7256    }
7257}
7258
7259/// Synthesize a minimal `TorrentMetaV1` from a `TorrentMetaV2` for session compatibility.
7260///
7261/// The session engine uses v1 structures internally (info hash as Id20, `InfoDict` for
7262/// piece hashing, etc.). For v2-only torrents, we create a "virtual" v1 representation
7263/// with the truncated SHA-256 hash as the `info_hash`.
7264/// M223 — off-actor add-torrent prep phase. Runs in a `tokio::spawn`'d
7265/// task launched by `SessionActor::try_spawn_add_torrent` so concurrent
7266/// adds do not serialise the actor's command queue. Performs the heavy
7267/// work that previously caused super-linear handler-cost growth in the
7268/// parallel-7 POST tail:
7269/// - synthesise / clone v1 metadata
7270/// - create the storage backend (filesystem or memory)
7271/// - `disk_manager.register_torrent` (async ack from disk actor)
7272/// - `TorrentHandle::from_torrent` (spawns the `TorrentActor`)
7273///
7274/// Returns `PreparedAddTorrent` on success — the `SessionActor` commit
7275/// arm then inserts it into `self.torrents` + queue position + alert +
7276/// LSD. Failures propagate untouched so the commit arm can reply with
7277/// the original error.
7278async fn prepare_add_torrent_off_actor(
7279    bundle: AddTorrentPrepBundle,
7280) -> crate::Result<PreparedAddTorrent> {
7281    let AddTorrentPrepBundle {
7282        torrent_meta,
7283        storage_override,
7284        torrent_config,
7285        disk_manager,
7286        dht_v4_broadcast,
7287        dht_v6_broadcast,
7288        global_up,
7289        global_down,
7290        slot_tuner,
7291        alert_tx,
7292        alert_mask,
7293        utp_socket,
7294        utp_socket_v6,
7295        ban_manager,
7296        ip_filter,
7297        plugins,
7298        sam_session,
7299        ssl_manager,
7300        factory,
7301        hash_pool,
7302        counters,
7303        m170_post,
7304    } = bundle;
7305
7306    let version = torrent_meta.version();
7307    let meta_v2 = torrent_meta.as_v2().cloned();
7308
7309    // For v2-only torrents, synthesize a minimal v1 metadata wrapper.
7310    // The session uses info_hash (Id20) as the primary key, so we use
7311    // the SHA-256 truncated to 20 bytes (per BEP 52 tracker/DHT compat).
7312    let meta = if let Some(v1) = torrent_meta.as_v1() {
7313        v1.clone()
7314    } else {
7315        let v2 = torrent_meta.as_v2().unwrap();
7316        synthesize_v1_from_v2(v2)
7317    };
7318    let info_hash = meta.info_hash;
7319    let is_private = meta.info.private == Some(1);
7320
7321    // Create or use provided storage, then register with disk manager
7322    let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7323        s
7324    } else {
7325        let lengths = Lengths::new(
7326            meta.info.total_length(),
7327            meta.info.piece_length,
7328            DEFAULT_CHUNK_SIZE,
7329        );
7330        let files = meta.info.files();
7331        let file_paths: Vec<PathBuf> = files
7332            .iter()
7333            .map(|f| f.path.iter().collect::<PathBuf>())
7334            .collect();
7335        let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7336        let prealloc_mode = torrent_config.preallocate_mode.unwrap_or_else(|| {
7337            irontide_storage::PreallocateMode::from(
7338                torrent_config.storage_mode == irontide_core::StorageMode::Full,
7339            )
7340        });
7341        match irontide_storage::FilesystemStorage::new(
7342            &torrent_config.download_dir,
7343            file_paths,
7344            file_lengths,
7345            lengths.clone(),
7346            None,
7347            prealloc_mode,
7348            torrent_config.filesystem_direct_io,
7349        ) {
7350            Ok(s) => Arc::new(s),
7351            Err(e) => {
7352                warn!("failed to create filesystem storage: {e}, falling back to memory");
7353                Arc::new(irontide_storage::MemoryStorage::new(lengths))
7354            }
7355        }
7356    };
7357    let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7358
7359    let handle = TorrentHandle::from_torrent(
7360        meta.clone(),
7361        version,
7362        meta_v2,
7363        disk_handle,
7364        disk_manager,
7365        torrent_config,
7366        dht_v4_broadcast.subscribe(),
7367        dht_v6_broadcast.subscribe(),
7368        global_up,
7369        global_down,
7370        slot_tuner,
7371        alert_tx.clone(),
7372        Arc::clone(&alert_mask),
7373        utp_socket,
7374        utp_socket_v6,
7375        ban_manager,
7376        ip_filter,
7377        plugins,
7378        sam_session,
7379        ssl_manager,
7380        factory,
7381        Some(hash_pool),
7382        counters,
7383    )
7384    .await?;
7385
7386    // M223 — post `TorrentAdded` here (sync, in the prep task) rather
7387    // than in `commit_add_torrent` (on the session actor) so the alert
7388    // ordering invariant survives spawn-per-add. `TorrentHandle::from_torrent`
7389    // spawns the `TorrentActor` internally but doesn't yield between
7390    // the spawn and its return, so this post races only with the
7391    // following `commit_tx.send.await` yield. The `TorrentActor`'s
7392    // first alert (`StateChanged → Checking` from `verify_existing_pieces`)
7393    // therefore fires AFTER this `TorrentAdded`.
7394    //
7395    // **Limitation**: parallel adds with the same info-hash both reach
7396    // here and both post `TorrentAdded`, even though the commit re-check
7397    // will fail one of them with `DuplicateTorrent`. The losing
7398    // `TorrentActor` shuts down cleanly when its `TorrentHandle` is
7399    // dropped in `commit_add_torrent`, but its `TorrentAdded` is a
7400    // false positive. Production callers do not parallelise same-hash
7401    // adds; accepted edge case.
7402    post_alert(
7403        &alert_tx,
7404        &alert_mask,
7405        AlertKind::TorrentAdded {
7406            info_hash,
7407            name: meta.info.name.clone(),
7408        },
7409    );
7410    Ok(PreparedAddTorrent {
7411        handle,
7412        info_hash,
7413        is_private,
7414        m170_post,
7415    })
7416}
7417
7418/// A per-torrent snapshot captured cheaply on the `SessionActor` so the actual
7419/// resume-file write can run OFF the recv loop (audit Tier-2 L1, M241).
7420/// `TorrentHandle` is `#[derive(Clone)]` over a channel sender, so cloning is
7421/// cheap; `queue_position`/`auto_managed` are copied as the `i64` the resume
7422/// format stores (matching the old inline conversion).
7423struct ResumeSaveJob {
7424    info_hash: Id20,
7425    handle: TorrentHandle,
7426    queue_position: i64,
7427    auto_managed: i64,
7428}
7429
7430/// Write resume files for every dirty torrent in `jobs`. Touches no
7431/// `SessionActor` state — only the cloned per-torrent handles + the snapshotted
7432/// queue metadata — so it is safe to `tokio::spawn` off the recv loop. Returns
7433/// the number of files successfully written. Per-torrent failures `warn` and
7434/// continue: one stopped/removed torrent must never abort the whole batch
7435/// (eng-review F2).
7436async fn run_resume_save_jobs(resume_dir: std::path::PathBuf, jobs: Vec<ResumeSaveJob>) -> usize {
7437    // F3 (eng-review): create_dir_all is blocking — run it on the blocking pool
7438    // so it never occupies an async worker thread.
7439    let torrents_dir = resume_dir.join("torrents");
7440    match tokio::task::spawn_blocking(move || std::fs::create_dir_all(&torrents_dir)).await {
7441        Ok(Ok(())) => {}
7442        Ok(Err(e)) => {
7443            warn!("failed to create resume dir: {e}");
7444            return 0;
7445        }
7446        Err(e) => {
7447            warn!("resume dir create task panicked: {e}");
7448            return 0;
7449        }
7450    }
7451
7452    let mut saved = 0usize;
7453    for job in &jobs {
7454        // F1 (M245) — atomically take resume data IFF dirty. One command turn
7455        // reads `need_save_resume`, builds the data, and clears the flag with no
7456        // `.await` between read and clear, closing the pre-M245 race where a
7457        // dirty mark set between the old `stats()` check and the separate
7458        // `clear_save_resume_flag()` was silently lost. `Ok(None)` => clean,
7459        // nothing to write. `Err(_)` => torrent shut down; warn+continue.
7460        let mut rd = match job.handle.take_resume_if_dirty().await {
7461            Ok(Some(rd)) => rd,
7462            Ok(None) => continue,
7463            Err(e) => {
7464                warn!(info_hash = %job.info_hash, "failed to take resume data: {e}");
7465                continue;
7466            }
7467        };
7468        rd.queue_position = job.queue_position;
7469        rd.auto_managed = job.auto_managed;
7470
7471        // From here the flag is already cleared. Any failure before the write
7472        // lands must re-arm it (D3) so the torrent is retried next cycle rather
7473        // than silently dropped. `resume_save_lock` serializes whole batches, so
7474        // no save-sequence number is needed to order concurrent writers.
7475        let bytes = match crate::resume_file::serialize_resume(&rd) {
7476            Ok(b) => b,
7477            Err(e) => {
7478                warn!(info_hash = %job.info_hash, "failed to serialize resume data: {e}");
7479                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7480                continue;
7481            }
7482        };
7483
7484        // Atomic write — blocking, so run it on the blocking pool (F3).
7485        let path = crate::resume_file::resume_file_path(&resume_dir, &job.info_hash);
7486        let write_res =
7487            tokio::task::spawn_blocking(move || crate::resume_file::atomic_write(&path, &bytes))
7488                .await;
7489        match write_res {
7490            Ok(Ok(())) => {}
7491            Ok(Err(e)) => {
7492                warn!(info_hash = %job.info_hash, "failed to write resume file: {e}");
7493                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7494                continue;
7495            }
7496            Err(e) => {
7497                warn!(info_hash = %job.info_hash, "resume write task panicked: {e}");
7498                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7499                continue;
7500            }
7501        }
7502
7503        saved = saved.saturating_add(1);
7504    }
7505    saved
7506}
7507
7508/// Re-arm a torrent's `need_save_resume` flag after [`run_resume_save_jobs`]
7509/// already cleared it (via `take_resume_if_dirty`) but the subsequent serialize
7510/// or disk write failed (M245 F1, eng-review D3). Without this the dirty state
7511/// would be lost and the torrent would not be retried until it next mutates.
7512async fn redirty_after_failed_save(handle: &TorrentHandle, info_hash: &Id20) {
7513    if let Err(e) = handle.mark_resume_dirty().await {
7514        warn!(info_hash = %info_hash, "failed to re-mark resume dirty after save failure: {e}");
7515    }
7516}
7517
7518fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7519    use irontide_core::{FileEntry, InfoDict};
7520
7521    let info_hash = v2.info_hashes.best_v1();
7522
7523    // Build file entries from v2 file tree
7524    let v2_files = v2.info.files();
7525    let file_entries: Vec<FileEntry> = v2_files
7526        .iter()
7527        .map(|f| FileEntry {
7528            length: f.attr.length,
7529            path: f.path.clone(),
7530            attr: None,
7531            mtime: None,
7532            symlink_path: None,
7533        })
7534        .collect();
7535
7536    // v2-only torrents have no v1 piece hashes — use placeholder pieces field.
7537    // Verification is done via v2 Merkle trees, not v1 SHA-1 hashes.
7538    let num_pieces = v2.info.num_pieces() as usize;
7539    let pieces = vec![0u8; num_pieces * 20];
7540
7541    let info = InfoDict {
7542        name: v2.info.name.clone(),
7543        piece_length: v2.info.piece_length,
7544        pieces,
7545        length: if file_entries.len() == 1 {
7546            Some(file_entries[0].length)
7547        } else {
7548            None
7549        },
7550        files: if file_entries.len() > 1 {
7551            Some(file_entries)
7552        } else {
7553            None
7554        },
7555        private: None,
7556        source: None,
7557        ssl_cert: v2.ssl_cert.clone(),
7558        similar: Vec::new(),
7559        collections: Vec::new(),
7560    };
7561
7562    irontide_core::TorrentMetaV1 {
7563        info_hash,
7564        announce: v2.announce.clone(),
7565        announce_list: v2.announce_list.clone(),
7566        comment: v2.comment.clone(),
7567        created_by: v2.created_by.clone(),
7568        creation_date: v2.creation_date,
7569        info,
7570        info_bytes: None,
7571        url_list: Vec::new(),
7572        httpseeds: Vec::new(),
7573        ssl_cert: v2.ssl_cert.clone(),
7574    }
7575}
7576
7577#[cfg(test)]
7578mod tests {
7579    use super::*;
7580    use crate::types::TorrentState;
7581    use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
7582    use irontide_storage::MemoryStorage;
7583    use std::time::Duration;
7584
7585    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
7586        use serde::Serialize;
7587
7588        #[derive(Serialize)]
7589        struct Info<'a> {
7590            length: u64,
7591            name: &'a str,
7592            #[serde(rename = "piece length")]
7593            piece_length: u64,
7594            #[serde(with = "serde_bytes")]
7595            pieces: &'a [u8],
7596        }
7597
7598        #[derive(Serialize)]
7599        struct Torrent<'a> {
7600            info: Info<'a>,
7601        }
7602
7603        let mut pieces = Vec::new();
7604        let mut offset = 0;
7605        while offset < data.len() {
7606            let end = (offset + piece_length as usize).min(data.len());
7607            let hash = irontide_core::sha1(&data[offset..end]);
7608            pieces.extend_from_slice(hash.as_bytes());
7609            offset = end;
7610        }
7611
7612        let t = Torrent {
7613            info: Info {
7614                length: data.len() as u64,
7615                name: "test",
7616                piece_length,
7617                pieces: &pieces,
7618            },
7619        };
7620
7621        let bytes = irontide_bencode::to_bytes(&t).unwrap();
7622        torrent_from_bytes(&bytes).unwrap()
7623    }
7624
7625    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
7626        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
7627        Arc::new(MemoryStorage::new(lengths))
7628    }
7629
7630    static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7631
7632    fn test_settings() -> Settings {
7633        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7634        let pid = std::process::id();
7635        let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
7636        let resume_dir =
7637            std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
7638        let _ = std::fs::remove_dir_all(&dl_dir);
7639        let _ = std::fs::remove_dir_all(&resume_dir);
7640        let _ = std::fs::create_dir_all(&dl_dir);
7641
7642        Settings {
7643            listen_port: 0,
7644            download_dir: dl_dir,
7645            resume_data_dir: Some(resume_dir),
7646            max_torrents: 10,
7647            enable_dht: false,
7648            enable_pex: false,
7649            enable_lsd: false,
7650            enable_fast_extension: false,
7651            enable_utp: false,
7652            enable_upnp: false,
7653            enable_natpmp: false,
7654            enable_ipv6: false,
7655            alert_channel_size: 64,
7656            disk_io_threads: 2,
7657            storage_mode: irontide_core::StorageMode::Sparse,
7658            disk_cache_size: 1024 * 1024,
7659            ..Settings::default()
7660        }
7661    }
7662
7663    // ---- Test 1: Start and shutdown ----
7664
7665    #[tokio::test]
7666    async fn session_start_and_shutdown() {
7667        let session = SessionHandle::start(test_settings()).await.unwrap();
7668        let stats = session.session_stats().await.unwrap();
7669        assert_eq!(stats.active_torrents, 0);
7670        session.shutdown().await.unwrap();
7671    }
7672
7673    #[tokio::test]
7674    async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
7675        let session = SessionHandle::start(test_settings()).await.unwrap();
7676        let bogus = Id20([0u8; 20]);
7677        let result = session.peer_unchoke_durations(bogus).await.unwrap();
7678        assert!(
7679            result.is_none(),
7680            "missing torrent must yield None, not an empty map"
7681        );
7682        session.shutdown().await.unwrap();
7683    }
7684
7685    #[tokio::test]
7686    async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
7687        let session = SessionHandle::start(test_settings()).await.unwrap();
7688        let data = vec![0xAB; 16384];
7689        let meta = make_test_torrent(&data, 16384);
7690        let storage = make_storage(&data, 16384);
7691        let info_hash = session
7692            .add_torrent_with_meta(meta.into(), Some(storage))
7693            .await
7694            .unwrap();
7695        let result = session
7696            .peer_unchoke_durations(info_hash)
7697            .await
7698            .unwrap()
7699            .expect("known torrent must yield Some, even with no peers");
7700        assert!(
7701            result.is_empty(),
7702            "fresh torrent with no peers has no unchoke history"
7703        );
7704        session.shutdown().await.unwrap();
7705    }
7706
7707    // ---- Test 2: Add and list torrent ----
7708
7709    #[tokio::test]
7710    async fn add_and_list_torrent() {
7711        let session = SessionHandle::start(test_settings()).await.unwrap();
7712        let data = vec![0xAB; 16384];
7713        let meta = make_test_torrent(&data, 16384);
7714        let expected_hash = meta.info_hash;
7715
7716        let storage = make_storage(&data, 16384);
7717        let info_hash = session
7718            .add_torrent_with_meta(meta.into(), Some(storage))
7719            .await
7720            .unwrap();
7721        assert_eq!(info_hash, expected_hash);
7722
7723        let list = session.list_torrents().await.unwrap();
7724        assert_eq!(list.len(), 1);
7725        assert!(list.contains(&info_hash));
7726
7727        session.shutdown().await.unwrap();
7728    }
7729
7730    // ---- Test 3: Remove torrent ----
7731
7732    #[tokio::test]
7733    async fn remove_torrent() {
7734        let session = SessionHandle::start(test_settings()).await.unwrap();
7735        let data = vec![0xAB; 16384];
7736        let meta = make_test_torrent(&data, 16384);
7737        let storage = make_storage(&data, 16384);
7738
7739        let info_hash = session
7740            .add_torrent_with_meta(meta.into(), Some(storage))
7741            .await
7742            .unwrap();
7743        session.remove_torrent(info_hash).await.unwrap();
7744
7745        tokio::time::sleep(Duration::from_millis(50)).await;
7746
7747        let list = session.list_torrents().await.unwrap();
7748        assert!(list.is_empty());
7749
7750        session.shutdown().await.unwrap();
7751    }
7752
7753    // ---- Test 4: Duplicate rejection ----
7754
7755    #[tokio::test]
7756    async fn duplicate_torrent_rejected() {
7757        let session = SessionHandle::start(test_settings()).await.unwrap();
7758        let data = vec![0xAB; 16384];
7759        let meta = make_test_torrent(&data, 16384);
7760        let storage1 = make_storage(&data, 16384);
7761        let storage2 = make_storage(&data, 16384);
7762
7763        session
7764            .add_torrent_with_meta(meta.clone().into(), Some(storage1))
7765            .await
7766            .unwrap();
7767        let result = session
7768            .add_torrent_with_meta(meta.into(), Some(storage2))
7769            .await;
7770        assert!(result.is_err());
7771        assert!(result.unwrap_err().to_string().contains("duplicate"));
7772
7773        session.shutdown().await.unwrap();
7774    }
7775
7776    // ---- Test 5: Max capacity ----
7777
7778    #[tokio::test]
7779    async fn session_at_capacity() {
7780        let mut config = test_settings();
7781        config.max_torrents = 1;
7782        let session = SessionHandle::start(config).await.unwrap();
7783
7784        let data1 = vec![0xAA; 16384];
7785        let meta1 = make_test_torrent(&data1, 16384);
7786        let storage1 = make_storage(&data1, 16384);
7787        session
7788            .add_torrent_with_meta(meta1.into(), Some(storage1))
7789            .await
7790            .unwrap();
7791
7792        let data2 = vec![0xBB; 16384];
7793        let meta2 = make_test_torrent(&data2, 16384);
7794        let storage2 = make_storage(&data2, 16384);
7795        let result = session
7796            .add_torrent_with_meta(meta2.into(), Some(storage2))
7797            .await;
7798        assert!(result.is_err());
7799        assert!(result.unwrap_err().to_string().contains("capacity"));
7800
7801        session.shutdown().await.unwrap();
7802    }
7803
7804    // ---- Test 6: Torrent stats ----
7805
7806    #[tokio::test]
7807    async fn torrent_stats_via_session() {
7808        let session = SessionHandle::start(test_settings()).await.unwrap();
7809        let data = vec![0xAB; 32768];
7810        let meta = make_test_torrent(&data, 16384);
7811        let storage = make_storage(&data, 16384);
7812
7813        let info_hash = session
7814            .add_torrent_with_meta(meta.into(), Some(storage))
7815            .await
7816            .unwrap();
7817        let stats = session.torrent_stats(info_hash).await.unwrap();
7818        assert_eq!(stats.state, TorrentState::Downloading);
7819        assert_eq!(stats.pieces_total, 2);
7820
7821        session.shutdown().await.unwrap();
7822    }
7823
7824    // ---- Test 7: Torrent info ----
7825
7826    #[tokio::test]
7827    async fn torrent_info_via_session() {
7828        let session = SessionHandle::start(test_settings()).await.unwrap();
7829        let data = vec![0xAB; 32768];
7830        let meta = make_test_torrent(&data, 16384);
7831        let storage = make_storage(&data, 16384);
7832
7833        let info_hash = session
7834            .add_torrent_with_meta(meta.into(), Some(storage))
7835            .await
7836            .unwrap();
7837        let info = session.torrent_info(info_hash).await.unwrap();
7838        assert_eq!(info.info_hash, info_hash);
7839        assert_eq!(info.name, "test");
7840        assert_eq!(info.total_length, 32768);
7841        assert_eq!(info.num_pieces, 2);
7842        assert!(!info.private);
7843        assert_eq!(info.files.len(), 1);
7844        assert_eq!(info.files[0].length, 32768);
7845
7846        session.shutdown().await.unwrap();
7847    }
7848
7849    // ---- Test 8: Pause/resume via session ----
7850
7851    #[tokio::test]
7852    async fn pause_resume_via_session() {
7853        let session = SessionHandle::start(test_settings()).await.unwrap();
7854        let data = vec![0xAB; 16384];
7855        let meta = make_test_torrent(&data, 16384);
7856        let storage = make_storage(&data, 16384);
7857
7858        let info_hash = session
7859            .add_torrent_with_meta(meta.into(), Some(storage))
7860            .await
7861            .unwrap();
7862
7863        session.pause_torrent(info_hash).await.unwrap();
7864        tokio::time::sleep(Duration::from_millis(50)).await;
7865        let stats = session.torrent_stats(info_hash).await.unwrap();
7866        assert_eq!(stats.state, TorrentState::Paused);
7867
7868        session.resume_torrent(info_hash).await.unwrap();
7869        tokio::time::sleep(Duration::from_millis(50)).await;
7870        let stats = session.torrent_stats(info_hash).await.unwrap();
7871        assert_eq!(stats.state, TorrentState::Downloading);
7872
7873        session.shutdown().await.unwrap();
7874    }
7875
7876    // ---- Test 9: Not-found errors ----
7877
7878    #[tokio::test]
7879    async fn not_found_errors() {
7880        let session = SessionHandle::start(test_settings()).await.unwrap();
7881        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7882
7883        assert!(session.torrent_stats(fake_hash).await.is_err());
7884        assert!(session.torrent_info(fake_hash).await.is_err());
7885        assert!(session.pause_torrent(fake_hash).await.is_err());
7886        assert!(session.resume_torrent(fake_hash).await.is_err());
7887        assert!(session.remove_torrent(fake_hash).await.is_err());
7888
7889        session.shutdown().await.unwrap();
7890    }
7891
7892    // ---- Test 10: Session stats ----
7893
7894    #[tokio::test]
7895    async fn session_stats_aggregate() {
7896        let session = SessionHandle::start(test_settings()).await.unwrap();
7897
7898        let data1 = vec![0xAA; 16384];
7899        let meta1 = make_test_torrent(&data1, 16384);
7900        let storage1 = make_storage(&data1, 16384);
7901        session
7902            .add_torrent_with_meta(meta1.into(), Some(storage1))
7903            .await
7904            .unwrap();
7905
7906        let data2 = vec![0xBB; 16384];
7907        let meta2 = make_test_torrent(&data2, 16384);
7908        let storage2 = make_storage(&data2, 16384);
7909        session
7910            .add_torrent_with_meta(meta2.into(), Some(storage2))
7911            .await
7912            .unwrap();
7913
7914        let stats = session.session_stats().await.unwrap();
7915        assert_eq!(stats.active_torrents, 2);
7916
7917        session.shutdown().await.unwrap();
7918    }
7919
7920    // ---- Test 11: Add magnet and list ----
7921
7922    #[tokio::test]
7923    async fn add_magnet_and_list() {
7924        use irontide_core::Magnet;
7925
7926        let session = SessionHandle::start(test_settings()).await.unwrap();
7927        let magnet = Magnet {
7928            info_hashes: irontide_core::InfoHashes::v1_only(
7929                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7930            ),
7931            display_name: Some("test-magnet".into()),
7932            trackers: vec![],
7933            peers: vec![],
7934            selected_files: None,
7935        };
7936        let expected_hash = magnet.info_hash();
7937
7938        let info_hash = session.add_magnet(magnet).await.unwrap();
7939        assert_eq!(info_hash, expected_hash);
7940
7941        let list = session.list_torrents().await.unwrap();
7942        assert_eq!(list.len(), 1);
7943        assert!(list.contains(&info_hash));
7944
7945        // torrent_info should fail with MetadataNotReady
7946        let err = session.torrent_info(info_hash).await.unwrap_err();
7947        assert!(err.to_string().contains("metadata not yet available"));
7948
7949        session.shutdown().await.unwrap();
7950    }
7951
7952    // ---- Test 12: Duplicate magnet rejected ----
7953
7954    #[tokio::test]
7955    async fn add_magnet_duplicate_rejected() {
7956        use irontide_core::Magnet;
7957
7958        let session = SessionHandle::start(test_settings()).await.unwrap();
7959        let magnet = Magnet {
7960            info_hashes: irontide_core::InfoHashes::v1_only(
7961                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7962            ),
7963            display_name: Some("test-magnet".into()),
7964            trackers: vec![],
7965            peers: vec![],
7966            selected_files: None,
7967        };
7968
7969        session.add_magnet(magnet.clone()).await.unwrap();
7970        let result = session.add_magnet(magnet).await;
7971        assert!(result.is_err());
7972        assert!(result.unwrap_err().to_string().contains("duplicate"));
7973
7974        session.shutdown().await.unwrap();
7975    }
7976
7977    // ---- Test 13: Session with LSD enabled ----
7978
7979    #[tokio::test]
7980    async fn session_with_lsd_enabled() {
7981        use irontide_core::Magnet;
7982
7983        // LSD may fail to bind port 6771 — session should still start
7984        let mut config = test_settings();
7985        config.enable_lsd = true;
7986
7987        let session = SessionHandle::start(config).await.unwrap();
7988
7989        // Add a torrent (triggers LSD announce if available)
7990        let data = vec![0xAB; 16384];
7991        let meta = make_test_torrent(&data, 16384);
7992        let storage = make_storage(&data, 16384);
7993        session
7994            .add_torrent_with_meta(meta.into(), Some(storage))
7995            .await
7996            .unwrap();
7997
7998        // Add a magnet (also triggers LSD announce)
7999        let magnet = Magnet {
8000            info_hashes: irontide_core::InfoHashes::v1_only(
8001                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8002            ),
8003            display_name: Some("lsd-test".into()),
8004            trackers: vec![],
8005            peers: vec![],
8006            selected_files: None,
8007        };
8008        session.add_magnet(magnet).await.unwrap();
8009
8010        let list = session.list_torrents().await.unwrap();
8011        assert_eq!(list.len(), 2);
8012
8013        session.shutdown().await.unwrap();
8014    }
8015
8016    // ---- Test: v2-only torrent addition ----
8017
8018    #[tokio::test]
8019    async fn add_v2_only_torrent() {
8020        use irontide_bencode::BencodeValue;
8021        use std::collections::BTreeMap;
8022
8023        let session = SessionHandle::start(test_settings()).await.unwrap();
8024
8025        // Build a minimal v2-only torrent
8026        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8027        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
8028        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8029        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
8030        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8031        ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
8032
8033        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8034        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
8035        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
8036        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
8037        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
8038
8039        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8040        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
8041
8042        let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
8043        let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
8044        assert!(meta.is_v2());
8045
8046        // This should NOT return an error now (v2-only is supported)
8047        let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
8048        let list = session.list_torrents().await.unwrap();
8049        assert!(list.contains(&info_hash));
8050
8051        session.shutdown().await.unwrap();
8052    }
8053
8054    // ---- Test 14: Save torrent resume data via session ----
8055
8056    #[tokio::test]
8057    async fn save_torrent_resume_data_via_session() {
8058        let session = SessionHandle::start(test_settings()).await.unwrap();
8059        let data = vec![0xAB; 32768];
8060        let meta = make_test_torrent(&data, 16384);
8061        let info_hash = meta.info_hash;
8062        let storage = make_storage(&data, 16384);
8063        session
8064            .add_torrent_with_meta(meta.into(), Some(storage))
8065            .await
8066            .unwrap();
8067
8068        let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
8069        assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
8070        assert_eq!(rd.name, "test");
8071        assert_eq!(rd.file_format, "libtorrent resume file");
8072        assert_eq!(rd.file_version, 1);
8073        assert!(!rd.pieces.is_empty());
8074        assert_eq!(rd.paused, 0);
8075
8076        session.shutdown().await.unwrap();
8077    }
8078
8079    // ---- Test 15: Save session state captures all torrents ----
8080
8081    #[tokio::test]
8082    async fn save_session_state_captures_all_torrents() {
8083        let session = SessionHandle::start(test_settings()).await.unwrap();
8084
8085        let data1 = vec![0xAA; 16384];
8086        let meta1 = make_test_torrent(&data1, 16384);
8087        let storage1 = make_storage(&data1, 16384);
8088        session
8089            .add_torrent_with_meta(meta1.into(), Some(storage1))
8090            .await
8091            .unwrap();
8092
8093        let data2 = vec![0xBB; 16384];
8094        let meta2 = make_test_torrent(&data2, 16384);
8095        let storage2 = make_storage(&data2, 16384);
8096        session
8097            .add_torrent_with_meta(meta2.into(), Some(storage2))
8098            .await
8099            .unwrap();
8100
8101        let state = session.save_session_state().await.unwrap();
8102        assert_eq!(state.torrents.len(), 2);
8103
8104        for rd in &state.torrents {
8105            assert_eq!(rd.file_format, "libtorrent resume file");
8106            assert_eq!(rd.info_hash.len(), 20);
8107        }
8108
8109        session.shutdown().await.unwrap();
8110    }
8111
8112    // ---- Test 16: Save resume data not found ----
8113
8114    #[tokio::test]
8115    async fn save_resume_data_not_found() {
8116        let session = SessionHandle::start(test_settings()).await.unwrap();
8117        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8118        let result = session.save_torrent_resume_data(fake_hash).await;
8119        assert!(result.is_err());
8120        assert!(result.unwrap_err().to_string().contains("not found"));
8121        session.shutdown().await.unwrap();
8122    }
8123
8124    // ---- Test 17: Subscribe receives TorrentAdded alert ----
8125
8126    #[tokio::test]
8127    async fn subscribe_receives_torrent_added_alert() {
8128        use crate::alert::AlertKind;
8129
8130        let session = SessionHandle::start(test_settings()).await.unwrap();
8131        let mut alerts = session.subscribe();
8132
8133        let data = vec![0xAB; 16384];
8134        let meta = make_test_torrent(&data, 16384);
8135        let storage = make_storage(&data, 16384);
8136        let _info_hash = session
8137            .add_torrent_with_meta(meta.into(), Some(storage))
8138            .await
8139            .unwrap();
8140
8141        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8142            .await
8143            .unwrap()
8144            .unwrap();
8145        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8146        session.shutdown().await.unwrap();
8147    }
8148
8149    // ---- Test 18: Subscribe receives TorrentRemoved alert ----
8150
8151    #[tokio::test]
8152    async fn subscribe_receives_torrent_removed_alert() {
8153        use crate::alert::AlertKind;
8154        use crate::types::TorrentState;
8155
8156        let session = SessionHandle::start(test_settings()).await.unwrap();
8157        let mut alerts = session.subscribe();
8158
8159        let data = vec![0xAB; 16384];
8160        let meta = make_test_torrent(&data, 16384);
8161        let storage = make_storage(&data, 16384);
8162        let info_hash = session
8163            .add_torrent_with_meta(meta.into(), Some(storage))
8164            .await
8165            .unwrap();
8166
8167        // Drain TorrentAdded and any checking alerts
8168        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8169            if matches!(
8170                a.kind,
8171                AlertKind::StateChanged {
8172                    new_state: TorrentState::Downloading,
8173                    ..
8174                }
8175            ) {
8176                break;
8177            }
8178        }
8179
8180        session.remove_torrent(info_hash).await.unwrap();
8181
8182        // Find TorrentRemoved (skip any interleaved alerts)
8183        loop {
8184            let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8185                .await
8186                .unwrap()
8187                .unwrap();
8188            if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8189                break;
8190            }
8191        }
8192        session.shutdown().await.unwrap();
8193    }
8194
8195    // ---- Test 19: Multiple subscribers each receive alerts ----
8196
8197    #[tokio::test]
8198    async fn multiple_subscribers_each_receive_alerts() {
8199        use crate::alert::AlertKind;
8200
8201        let session = SessionHandle::start(test_settings()).await.unwrap();
8202        let mut sub1 = session.subscribe();
8203        let mut sub2 = session.subscribe();
8204
8205        let data = vec![0xAB; 16384];
8206        let meta = make_test_torrent(&data, 16384);
8207        let storage = make_storage(&data, 16384);
8208        session
8209            .add_torrent_with_meta(meta.into(), Some(storage))
8210            .await
8211            .unwrap();
8212
8213        let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8214            .await
8215            .unwrap()
8216            .unwrap();
8217        let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8218            .await
8219            .unwrap()
8220            .unwrap();
8221
8222        assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8223        assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8224        session.shutdown().await.unwrap();
8225    }
8226
8227    // ---- Test 20: set_alert_mask filters at runtime ----
8228
8229    #[tokio::test]
8230    async fn set_alert_mask_filters_at_runtime() {
8231        use crate::alert::{AlertCategory, AlertKind};
8232
8233        let session = SessionHandle::start(test_settings()).await.unwrap();
8234        let mut alerts = session.subscribe();
8235
8236        // Start with ALL — TorrentAdded (STATUS) should arrive
8237        let data = vec![0xAB; 16384];
8238        let meta = make_test_torrent(&data, 16384);
8239        let storage = make_storage(&data, 16384);
8240        session
8241            .add_torrent_with_meta(meta.into(), Some(storage))
8242            .await
8243            .unwrap();
8244
8245        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8246            .await
8247            .unwrap()
8248            .unwrap();
8249        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8250
8251        // Drain any remaining alerts from the first torrent (StateChanged, CheckingProgress, etc.)
8252        while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8253            .await
8254            .is_ok()
8255        {}
8256
8257        // Change mask to empty — no alerts should pass
8258        session.set_alert_mask(AlertCategory::empty());
8259
8260        let data2 = vec![0xBB; 16384];
8261        let meta2 = make_test_torrent(&data2, 16384);
8262        let storage2 = make_storage(&data2, 16384);
8263        session
8264            .add_torrent_with_meta(meta2.into(), Some(storage2))
8265            .await
8266            .unwrap();
8267
8268        // Give a small window — nothing should arrive
8269        let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8270        assert!(result.is_err(), "should have timed out with empty mask");
8271
8272        // Restore STATUS — adding another torrent should arrive
8273        session.set_alert_mask(AlertCategory::STATUS);
8274
8275        let data3 = vec![0xCC; 16384];
8276        let meta3 = make_test_torrent(&data3, 16384);
8277        let storage3 = make_storage(&data3, 16384);
8278        session
8279            .add_torrent_with_meta(meta3.into(), Some(storage3))
8280            .await
8281            .unwrap();
8282
8283        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8284            .await
8285            .unwrap()
8286            .unwrap();
8287        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8288
8289        session.shutdown().await.unwrap();
8290    }
8291
8292    // ---- Test 21: AlertStream filters per subscriber ----
8293
8294    #[tokio::test]
8295    async fn alert_stream_filters_per_subscriber() {
8296        use crate::alert::{AlertCategory, AlertKind};
8297
8298        let session = SessionHandle::start(test_settings()).await.unwrap();
8299
8300        // subscriber A: STATUS only
8301        let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8302        // subscriber B: PEER only
8303        let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8304
8305        let data = vec![0xAB; 16384];
8306        let meta = make_test_torrent(&data, 16384);
8307        let storage = make_storage(&data, 16384);
8308        session
8309            .add_torrent_with_meta(meta.into(), Some(storage))
8310            .await
8311            .unwrap();
8312
8313        // STATUS sub gets TorrentAdded
8314        let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8315            .await
8316            .unwrap()
8317            .unwrap();
8318        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8319
8320        // PEER sub should NOT receive TorrentAdded (it's STATUS category)
8321        let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8322        assert!(
8323            result.is_err(),
8324            "PEER subscriber should not get STATUS alerts"
8325        );
8326
8327        session.shutdown().await.unwrap();
8328    }
8329
8330    // ---- Test 22: State changed tracks transitions ----
8331
8332    #[tokio::test]
8333    async fn state_changed_tracks_transitions() {
8334        use crate::alert::AlertKind;
8335
8336        let session = SessionHandle::start(test_settings()).await.unwrap();
8337        let mut alerts = session.subscribe();
8338
8339        let data = vec![0xAB; 16384];
8340        let meta = make_test_torrent(&data, 16384);
8341        let storage = make_storage(&data, 16384);
8342        let info_hash = session
8343            .add_torrent_with_meta(meta.into(), Some(storage))
8344            .await
8345            .unwrap();
8346
8347        // Drain TorrentAdded
8348        let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8349            .await
8350            .unwrap();
8351
8352        // Pause — should get StateChanged(Downloading → Paused) + TorrentPaused
8353        session.pause_torrent(info_hash).await.unwrap();
8354        tokio::time::sleep(Duration::from_millis(100)).await;
8355
8356        // Collect alerts over a short window
8357        let mut state_changes = Vec::new();
8358        let mut paused_alerts = Vec::new();
8359        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8360        {
8361            match &a.kind {
8362                AlertKind::StateChanged {
8363                    prev_state,
8364                    new_state,
8365                    ..
8366                } => {
8367                    state_changes.push((*prev_state, *new_state));
8368                }
8369                AlertKind::TorrentPaused { .. } => {
8370                    paused_alerts.push(a);
8371                }
8372                _ => {} // other alerts (PeerConnected etc)
8373            }
8374        }
8375
8376        assert!(
8377            state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8378            "expected Downloading→Paused, got: {state_changes:?}"
8379        );
8380        assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8381
8382        // Resume — should get StateChanged(Paused → Downloading) + TorrentResumed
8383        session.resume_torrent(info_hash).await.unwrap();
8384        tokio::time::sleep(Duration::from_millis(100)).await;
8385
8386        let mut resume_state_changes = Vec::new();
8387        let mut resumed_alerts = Vec::new();
8388        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8389        {
8390            match &a.kind {
8391                AlertKind::StateChanged {
8392                    prev_state,
8393                    new_state,
8394                    ..
8395                } => {
8396                    resume_state_changes.push((*prev_state, *new_state));
8397                }
8398                AlertKind::TorrentResumed { .. } => {
8399                    resumed_alerts.push(a);
8400                }
8401                _ => {}
8402            }
8403        }
8404
8405        assert!(
8406            resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8407            "expected Paused→Downloading, got: {resume_state_changes:?}"
8408        );
8409        assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8410
8411        session.shutdown().await.unwrap();
8412    }
8413
8414    #[tokio::test]
8415    async fn session_config_creates_utp_socket() {
8416        // Start session with uTP enabled — should succeed without errors
8417        let mut config = test_settings();
8418        config.enable_utp = true;
8419        let session = SessionHandle::start(config).await.unwrap();
8420        let stats = session.session_stats().await.unwrap();
8421        assert_eq!(stats.active_torrents, 0);
8422        session.shutdown().await.unwrap();
8423    }
8424
8425    #[test]
8426    fn settings_nat_defaults() {
8427        let s = Settings::default();
8428        assert!(s.enable_upnp, "enable_upnp should default to true");
8429        assert!(s.enable_natpmp, "enable_natpmp should default to true");
8430    }
8431
8432    #[tokio::test]
8433    async fn session_with_nat_disabled() {
8434        let config = test_settings();
8435        // test_session_config already sets enable_upnp: false, enable_natpmp: false
8436        assert!(!config.enable_upnp);
8437        assert!(!config.enable_natpmp);
8438        let session = SessionHandle::start(config).await.unwrap();
8439        let stats = session.session_stats().await.unwrap();
8440        assert_eq!(stats.active_torrents, 0);
8441        session.shutdown().await.unwrap();
8442    }
8443
8444    // ---- M29: Anonymous mode, force proxy, proxy config tests ----
8445
8446    #[test]
8447    fn anonymous_mode_disables_discovery() {
8448        let mut config = test_settings();
8449        config.anonymous_mode = true;
8450        config.enable_dht = true;
8451        config.enable_lsd = true;
8452        config.enable_upnp = true;
8453        config.enable_natpmp = true;
8454
8455        // SessionHandle::start() will override these when anonymous_mode is true.
8456        // We test the enforcement logic directly here.
8457        if config.anonymous_mode {
8458            config.enable_dht = false;
8459            config.enable_lsd = false;
8460            config.enable_upnp = false;
8461            config.enable_natpmp = false;
8462        }
8463
8464        assert!(!config.enable_dht);
8465        assert!(!config.enable_lsd);
8466        assert!(!config.enable_upnp);
8467        assert!(!config.enable_natpmp);
8468    }
8469
8470    #[tokio::test]
8471    async fn anonymous_mode_session_starts_with_discovery_disabled() {
8472        let mut config = test_settings();
8473        config.anonymous_mode = true;
8474        // Even if we enable these, anonymous_mode should override
8475        config.enable_dht = true;
8476        config.enable_lsd = true;
8477
8478        let session = SessionHandle::start(config).await.unwrap();
8479        let stats = session.session_stats().await.unwrap();
8480        assert_eq!(stats.active_torrents, 0);
8481        session.shutdown().await.unwrap();
8482    }
8483
8484    #[test]
8485    fn force_proxy_requires_proxy_configured() {
8486        let mut config = test_settings();
8487        config.force_proxy = true;
8488        config.proxy = crate::proxy::ProxyConfig::default(); // no proxy
8489
8490        // Validate the config error
8491        assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8492        assert!(config.force_proxy);
8493        // This would error in SessionHandle::start()
8494    }
8495
8496    #[tokio::test]
8497    async fn force_proxy_errors_without_proxy() {
8498        let mut config = test_settings();
8499        config.force_proxy = true;
8500        // proxy_type is None by default
8501
8502        let result = SessionHandle::start(config).await;
8503        assert!(result.is_err());
8504        match result {
8505            Err(e) => assert!(
8506                e.to_string().contains("force_proxy"),
8507                "error should mention force_proxy: {e}"
8508            ),
8509            Ok(_) => panic!("expected error"),
8510        }
8511    }
8512
8513    #[test]
8514    fn force_proxy_disables_features() {
8515        let mut config = test_settings();
8516        config.force_proxy = true;
8517        config.proxy = crate::proxy::ProxyConfig {
8518            proxy_type: crate::proxy::ProxyType::Socks5,
8519            hostname: "proxy.example.com".into(),
8520            port: 1080,
8521            ..Default::default()
8522        };
8523        config.enable_dht = true;
8524        config.enable_lsd = true;
8525        config.enable_upnp = true;
8526        config.enable_natpmp = true;
8527
8528        // Simulate the enforcement from start()
8529        if config.force_proxy {
8530            config.enable_upnp = false;
8531            config.enable_natpmp = false;
8532            config.enable_dht = false;
8533            config.enable_lsd = false;
8534        }
8535
8536        assert!(!config.enable_dht);
8537        assert!(!config.enable_lsd);
8538        assert!(!config.enable_upnp);
8539        assert!(!config.enable_natpmp);
8540    }
8541
8542    #[test]
8543    fn proxy_config_round_trip() {
8544        let s = Settings {
8545            proxy: crate::proxy::ProxyConfig {
8546                proxy_type: crate::proxy::ProxyType::Socks5Password,
8547                hostname: "localhost".into(),
8548                port: 9050,
8549                username: Some("user".into()),
8550                password: Some("pass".into()),
8551                ..Default::default()
8552            },
8553            force_proxy: true,
8554            anonymous_mode: true,
8555            ..test_settings()
8556        };
8557
8558        assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
8559        assert_eq!(s.proxy.hostname, "localhost");
8560        assert_eq!(s.proxy.port, 9050);
8561        assert!(s.force_proxy);
8562        assert!(s.anonymous_mode);
8563        assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
8564    }
8565
8566    #[tokio::test]
8567    async fn apply_settings_runtime() {
8568        let session = SessionHandle::start(test_settings()).await.unwrap();
8569        let original = session.settings().await.unwrap();
8570        assert_eq!(original.max_torrents, 10);
8571
8572        let mut new = original.clone();
8573        new.max_torrents = 200;
8574        new.upload_rate_limit = 1_000_000;
8575        session.apply_settings(new).await.unwrap();
8576
8577        let updated = session.settings().await.unwrap();
8578        assert_eq!(updated.max_torrents, 200);
8579        assert_eq!(updated.upload_rate_limit, 1_000_000);
8580
8581        session.shutdown().await.unwrap();
8582    }
8583
8584    #[tokio::test]
8585    async fn apply_settings_validation_error() {
8586        let session = SessionHandle::start(test_settings()).await.unwrap();
8587
8588        // force_proxy=true without a proxy configured should fail validation
8589        let bad = Settings {
8590            force_proxy: true,
8591            ..Settings::default()
8592        };
8593        let result = session.apply_settings(bad).await;
8594        assert!(result.is_err());
8595
8596        // Original settings should be unchanged
8597        let current = session.settings().await.unwrap();
8598        assert!(!current.force_proxy);
8599
8600        session.shutdown().await.unwrap();
8601    }
8602
8603    // ---- M50: Session stats counters tests ----
8604
8605    #[tokio::test]
8606    async fn session_stats_counters_accessible() {
8607        let session = SessionHandle::start(test_settings()).await.unwrap();
8608        let counters = session.counters();
8609        // Exercise the uptime accessor (returns u64, so >= 0 is tautological;
8610        // the meaningful check is that the call doesn't panic and counters
8611        // are wired up).
8612        let _ = counters.uptime_secs();
8613        assert_eq!(counters.len(), crate::stats::NUM_METRICS);
8614        session.shutdown().await.unwrap();
8615    }
8616
8617    #[tokio::test]
8618    async fn post_session_stats_fires_alert() {
8619        use crate::alert::{AlertCategory, AlertKind};
8620
8621        let session = SessionHandle::start(test_settings()).await.unwrap();
8622        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8623
8624        session.post_session_stats().await.unwrap();
8625
8626        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8627            .await
8628            .expect("timed out waiting for SessionStatsAlert")
8629            .expect("recv error");
8630        assert!(
8631            matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
8632            "expected SessionStatsAlert with {} values, got {:?}",
8633            crate::stats::NUM_METRICS,
8634            alert.kind,
8635        );
8636        session.shutdown().await.unwrap();
8637    }
8638
8639    #[tokio::test]
8640    async fn session_stats_include_torrent_count() {
8641        use crate::alert::{AlertCategory, AlertKind};
8642
8643        let session = SessionHandle::start(test_settings()).await.unwrap();
8644        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8645
8646        // Add a torrent
8647        let data = vec![0xAB; 16384];
8648        let meta = make_test_torrent(&data, 16384);
8649        let storage = make_storage(&data, 16384);
8650        session
8651            .add_torrent_with_meta(meta.into(), Some(storage))
8652            .await
8653            .unwrap();
8654
8655        session.post_session_stats().await.unwrap();
8656
8657        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8658            .await
8659            .expect("timed out waiting for SessionStatsAlert")
8660            .expect("recv error");
8661        match alert.kind {
8662            AlertKind::SessionStatsAlert { values } => {
8663                assert!(
8664                    values[crate::stats::SES_NUM_TORRENTS] > 0,
8665                    "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
8666                    values[crate::stats::SES_NUM_TORRENTS],
8667                );
8668            }
8669            other => panic!("expected SessionStatsAlert, got {other:?}"),
8670        }
8671        session.shutdown().await.unwrap();
8672    }
8673
8674    #[tokio::test]
8675    async fn stats_timer_disabled_when_zero() {
8676        use crate::alert::AlertCategory;
8677
8678        let mut config = test_settings();
8679        config.stats_report_interval = 0;
8680        let session = SessionHandle::start(config).await.unwrap();
8681        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8682
8683        // Wait 200ms — no periodic stats alert should arrive
8684        let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
8685        assert!(
8686            result.is_err(),
8687            "no SessionStatsAlert should fire when stats_report_interval is 0"
8688        );
8689        session.shutdown().await.unwrap();
8690    }
8691
8692    #[tokio::test]
8693    async fn sample_infohashes_timer_disabled_when_zero() {
8694        use crate::alert::AlertCategory;
8695
8696        let mut config = test_settings();
8697        config.dht_sample_infohashes_interval = 0;
8698        let session = SessionHandle::start(config).await.unwrap();
8699        let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
8700
8701        // Wait 200ms — no DhtSampleInfohashes alert should arrive
8702        let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
8703        assert!(
8704            result.is_err(),
8705            "no DhtSampleInfohashes alert should fire when interval is 0"
8706        );
8707        session.shutdown().await.unwrap();
8708    }
8709
8710    // ---- Test: open_file returns TorrentNotFound for unknown hash ----
8711
8712    #[tokio::test]
8713    async fn open_file_not_found() {
8714        let session = SessionHandle::start(test_settings()).await.unwrap();
8715        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8716        let result = session.open_file(fake_hash, 0).await;
8717        assert!(result.is_err());
8718        let err = result.err().unwrap();
8719        assert!(err.to_string().contains("not found"));
8720        session.shutdown().await.unwrap();
8721    }
8722
8723    // ---- Test: open_file on a real torrent routes to TorrentHandle ----
8724
8725    #[tokio::test]
8726    async fn open_file_routes_to_torrent() {
8727        let session = SessionHandle::start(test_settings()).await.unwrap();
8728        let data = vec![0xAB; 32768];
8729        let meta = make_test_torrent(&data, 16384);
8730        let storage = make_storage(&data, 16384);
8731
8732        let info_hash = session
8733            .add_torrent_with_meta(meta.into(), Some(storage))
8734            .await
8735            .unwrap();
8736
8737        // open_file should succeed for file_index 0 (single-file torrent)
8738        let stream = session.open_file(info_hash, 0).await;
8739        assert!(stream.is_ok(), "open_file should succeed for file_index 0");
8740
8741        // open_file should fail for out-of-range file_index
8742        let result = session.open_file(info_hash, 999).await;
8743        assert!(
8744            result.is_err(),
8745            "open_file should fail for invalid file_index"
8746        );
8747
8748        session.shutdown().await.unwrap();
8749    }
8750
8751    // ---- Test: force_reannounce via session ----
8752
8753    #[tokio::test]
8754    async fn session_force_reannounce() {
8755        let session = SessionHandle::start(test_settings()).await.unwrap();
8756        let data = vec![0xAB; 16384];
8757        let meta = make_test_torrent(&data, 16384);
8758        let storage = make_storage(&data, 16384);
8759        let info_hash = session
8760            .add_torrent_with_meta(meta.into(), Some(storage))
8761            .await
8762            .unwrap();
8763
8764        // Should succeed for a known torrent.
8765        let result = session.force_reannounce(info_hash).await;
8766        assert!(
8767            result.is_ok(),
8768            "force_reannounce should succeed: {result:?}"
8769        );
8770
8771        // Should fail for unknown torrent.
8772        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8773        assert!(session.force_reannounce(fake).await.is_err());
8774
8775        session.shutdown().await.unwrap();
8776    }
8777
8778    // ---- Test: tracker_list via session ----
8779
8780    #[tokio::test]
8781    async fn session_tracker_list() {
8782        let session = SessionHandle::start(test_settings()).await.unwrap();
8783        let data = vec![0xAB; 16384];
8784        let meta = make_test_torrent(&data, 16384);
8785        let storage = make_storage(&data, 16384);
8786        let info_hash = session
8787            .add_torrent_with_meta(meta.into(), Some(storage))
8788            .await
8789            .unwrap();
8790
8791        // Should succeed (empty list since test torrent has no announce URL).
8792        let trackers = session.tracker_list(info_hash).await.unwrap();
8793        assert!(trackers.is_empty(), "test torrent has no trackers");
8794
8795        // Should fail for unknown torrent.
8796        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8797        assert!(session.tracker_list(fake).await.is_err());
8798
8799        session.shutdown().await.unwrap();
8800    }
8801
8802    // ---- Test: scrape via session ----
8803
8804    #[tokio::test]
8805    async fn session_scrape() {
8806        let session = SessionHandle::start(test_settings()).await.unwrap();
8807        let data = vec![0xAB; 16384];
8808        let meta = make_test_torrent(&data, 16384);
8809        let storage = make_storage(&data, 16384);
8810        let info_hash = session
8811            .add_torrent_with_meta(meta.into(), Some(storage))
8812            .await
8813            .unwrap();
8814
8815        // Should succeed (None since test torrent has no trackers to scrape).
8816        let scrape = session.scrape(info_hash).await.unwrap();
8817        assert!(scrape.is_none(), "test torrent has no trackers to scrape");
8818
8819        // Should fail for unknown torrent.
8820        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8821        assert!(session.scrape(fake).await.is_err());
8822
8823        session.shutdown().await.unwrap();
8824    }
8825
8826    // ---- Test: set_file_priority via session ----
8827
8828    #[tokio::test]
8829    async fn session_set_file_priority() {
8830        let session = SessionHandle::start(test_settings()).await.unwrap();
8831        let data = vec![0xAB; 16384];
8832        let meta = make_test_torrent(&data, 16384);
8833        let storage = make_storage(&data, 16384);
8834        let info_hash = session
8835            .add_torrent_with_meta(meta.into(), Some(storage))
8836            .await
8837            .unwrap();
8838
8839        // Should succeed for file index 0 (single-file torrent).
8840        let result = session
8841            .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
8842            .await;
8843        assert!(
8844            result.is_ok(),
8845            "set_file_priority should succeed: {result:?}"
8846        );
8847
8848        // Should fail for unknown torrent.
8849        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8850        assert!(
8851            session
8852                .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
8853                .await
8854                .is_err()
8855        );
8856
8857        session.shutdown().await.unwrap();
8858    }
8859
8860    // ---- Test: file_priorities via session ----
8861
8862    #[tokio::test]
8863    async fn session_file_priorities() {
8864        let session = SessionHandle::start(test_settings()).await.unwrap();
8865        let data = vec![0xAB; 16384];
8866        let meta = make_test_torrent(&data, 16384);
8867        let storage = make_storage(&data, 16384);
8868        let info_hash = session
8869            .add_torrent_with_meta(meta.into(), Some(storage))
8870            .await
8871            .unwrap();
8872
8873        // Should return priorities for the single file.
8874        let priorities = session.file_priorities(info_hash).await.unwrap();
8875        assert_eq!(
8876            priorities.len(),
8877            1,
8878            "single-file torrent should have 1 file priority"
8879        );
8880        assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
8881
8882        // Should fail for unknown torrent.
8883        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8884        assert!(session.file_priorities(fake).await.is_err());
8885
8886        session.shutdown().await.unwrap();
8887    }
8888
8889    // ---- Test: set_download_limit zero means unlimited ----
8890
8891    #[tokio::test]
8892    async fn set_download_limit_zero_means_unlimited() {
8893        let session = SessionHandle::start(test_settings()).await.unwrap();
8894        let data = vec![0xAB; 16384];
8895        let meta = make_test_torrent(&data, 16384);
8896        let storage = make_storage(&data, 16384);
8897        let info_hash = session
8898            .add_torrent_with_meta(meta.into(), Some(storage))
8899            .await
8900            .unwrap();
8901
8902        // Set limit to non-zero, then back to zero (unlimited).
8903        session.set_download_limit(info_hash, 50_000).await.unwrap();
8904        session.set_download_limit(info_hash, 0).await.unwrap();
8905        let limit = session.download_limit(info_hash).await.unwrap();
8906        assert_eq!(limit, 0, "0 means unlimited");
8907
8908        session.shutdown().await.unwrap();
8909    }
8910
8911    // ---- Test: set_upload_limit persists ----
8912
8913    #[tokio::test]
8914    async fn set_upload_limit_persists() {
8915        let session = SessionHandle::start(test_settings()).await.unwrap();
8916        let data = vec![0xAB; 16384];
8917        let meta = make_test_torrent(&data, 16384);
8918        let storage = make_storage(&data, 16384);
8919        let info_hash = session
8920            .add_torrent_with_meta(meta.into(), Some(storage))
8921            .await
8922            .unwrap();
8923
8924        session.set_upload_limit(info_hash, 100_000).await.unwrap();
8925        let limit = session.upload_limit(info_hash).await.unwrap();
8926        assert_eq!(limit, 100_000);
8927
8928        session.shutdown().await.unwrap();
8929    }
8930
8931    // ---- Test: download_limit default is zero ----
8932
8933    #[tokio::test]
8934    async fn download_limit_default_is_zero() {
8935        let session = SessionHandle::start(test_settings()).await.unwrap();
8936        let data = vec![0xAB; 16384];
8937        let meta = make_test_torrent(&data, 16384);
8938        let storage = make_storage(&data, 16384);
8939        let info_hash = session
8940            .add_torrent_with_meta(meta.into(), Some(storage))
8941            .await
8942            .unwrap();
8943
8944        // Default config has download_rate_limit = 0.
8945        let limit = session.download_limit(info_hash).await.unwrap();
8946        assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
8947
8948        session.shutdown().await.unwrap();
8949    }
8950
8951    // ---- Test: rate_limit_round_trip ----
8952
8953    #[tokio::test]
8954    async fn rate_limit_round_trip() {
8955        let session = SessionHandle::start(test_settings()).await.unwrap();
8956        let data = vec![0xAB; 16384];
8957        let meta = make_test_torrent(&data, 16384);
8958        let storage = make_storage(&data, 16384);
8959        let info_hash = session
8960            .add_torrent_with_meta(meta.into(), Some(storage))
8961            .await
8962            .unwrap();
8963
8964        // Set both limits.
8965        session
8966            .set_download_limit(info_hash, 1_000_000)
8967            .await
8968            .unwrap();
8969        session.set_upload_limit(info_hash, 500_000).await.unwrap();
8970
8971        // Read them back.
8972        let dl = session.download_limit(info_hash).await.unwrap();
8973        let ul = session.upload_limit(info_hash).await.unwrap();
8974        assert_eq!(dl, 1_000_000);
8975        assert_eq!(ul, 500_000);
8976
8977        // Update and verify again.
8978        session
8979            .set_download_limit(info_hash, 2_000_000)
8980            .await
8981            .unwrap();
8982        let dl = session.download_limit(info_hash).await.unwrap();
8983        assert_eq!(dl, 2_000_000);
8984
8985        // Unknown torrent should fail.
8986        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8987        assert!(session.download_limit(fake).await.is_err());
8988        assert!(session.upload_limit(fake).await.is_err());
8989        assert!(session.set_download_limit(fake, 100).await.is_err());
8990        assert!(session.set_upload_limit(fake, 100).await.is_err());
8991
8992        session.shutdown().await.unwrap();
8993    }
8994
8995    // ---- Test: sequential_download_toggle ----
8996
8997    #[tokio::test]
8998    async fn sequential_download_toggle() {
8999        let session = SessionHandle::start(test_settings()).await.unwrap();
9000        let data = vec![0xAB; 16384];
9001        let meta = make_test_torrent(&data, 16384);
9002        let storage = make_storage(&data, 16384);
9003        let info_hash = session
9004            .add_torrent_with_meta(meta.into(), Some(storage))
9005            .await
9006            .unwrap();
9007
9008        // Enable sequential download.
9009        session
9010            .set_sequential_download(info_hash, true)
9011            .await
9012            .unwrap();
9013        assert!(session.is_sequential_download(info_hash).await.unwrap());
9014
9015        // Disable it again.
9016        session
9017            .set_sequential_download(info_hash, false)
9018            .await
9019            .unwrap();
9020        assert!(!session.is_sequential_download(info_hash).await.unwrap());
9021
9022        session.shutdown().await.unwrap();
9023    }
9024
9025    // ---- Test: super_seeding_toggle ----
9026
9027    #[tokio::test]
9028    async fn super_seeding_toggle() {
9029        let session = SessionHandle::start(test_settings()).await.unwrap();
9030        let data = vec![0xAB; 16384];
9031        let meta = make_test_torrent(&data, 16384);
9032        let storage = make_storage(&data, 16384);
9033        let info_hash = session
9034            .add_torrent_with_meta(meta.into(), Some(storage))
9035            .await
9036            .unwrap();
9037
9038        // Enable super seeding.
9039        session.set_super_seeding(info_hash, true).await.unwrap();
9040        assert!(session.is_super_seeding(info_hash).await.unwrap());
9041
9042        // Disable it again.
9043        session.set_super_seeding(info_hash, false).await.unwrap();
9044        assert!(!session.is_super_seeding(info_hash).await.unwrap());
9045
9046        session.shutdown().await.unwrap();
9047    }
9048
9049    // ---- Test: sequential_download_default_false ----
9050
9051    #[tokio::test]
9052    async fn sequential_download_default_false() {
9053        let session = SessionHandle::start(test_settings()).await.unwrap();
9054        let data = vec![0xAB; 16384];
9055        let meta = make_test_torrent(&data, 16384);
9056        let storage = make_storage(&data, 16384);
9057        let info_hash = session
9058            .add_torrent_with_meta(meta.into(), Some(storage))
9059            .await
9060            .unwrap();
9061
9062        // Default config has sequential_download = false.
9063        assert!(!session.is_sequential_download(info_hash).await.unwrap());
9064
9065        session.shutdown().await.unwrap();
9066    }
9067
9068    // ---- Test: super_seeding_default_false ----
9069
9070    #[tokio::test]
9071    async fn super_seeding_default_false() {
9072        let session = SessionHandle::start(test_settings()).await.unwrap();
9073        let data = vec![0xAB; 16384];
9074        let meta = make_test_torrent(&data, 16384);
9075        let storage = make_storage(&data, 16384);
9076        let info_hash = session
9077            .add_torrent_with_meta(meta.into(), Some(storage))
9078            .await
9079            .unwrap();
9080
9081        // Default config has super_seeding = false.
9082        assert!(!session.is_super_seeding(info_hash).await.unwrap());
9083
9084        session.shutdown().await.unwrap();
9085    }
9086
9087    // ---- M159 Test: seed_mode_flips_user_flag ----
9088
9089    #[tokio::test]
9090    async fn seed_mode_flips_user_flag() {
9091        let session = SessionHandle::start(test_settings()).await.unwrap();
9092        let data = vec![0xAB; 16384];
9093        let meta = make_test_torrent(&data, 16384);
9094        let storage = make_storage(&data, 16384);
9095        let info_hash = session
9096            .add_torrent_with_meta(meta.into(), Some(storage))
9097            .await
9098            .unwrap();
9099
9100        // Initial state: user_seed_mode defaults to false.
9101        let stats_before = session.torrent_stats(info_hash).await.unwrap();
9102        assert!(
9103            !stats_before.user_seed_mode,
9104            "new torrent should not start in user seed mode"
9105        );
9106
9107        // Enable user seed mode.
9108        session.set_seed_mode(info_hash, true).await.unwrap();
9109        let stats_on = session.torrent_stats(info_hash).await.unwrap();
9110        assert!(
9111            stats_on.user_seed_mode,
9112            "stats should reflect user_seed_mode=true after enabling"
9113        );
9114
9115        // Disable it again.
9116        session.set_seed_mode(info_hash, false).await.unwrap();
9117        let stats_off = session.torrent_stats(info_hash).await.unwrap();
9118        assert!(
9119            !stats_off.user_seed_mode,
9120            "stats should reflect user_seed_mode=false after disabling"
9121        );
9122
9123        session.shutdown().await.unwrap();
9124    }
9125
9126    // ---- M159 Test: seed_mode_round_trip ----
9127
9128    #[tokio::test]
9129    async fn seed_mode_round_trip() {
9130        // Five flips in a row, exercising the actor's toggle idempotency and
9131        // piece-reservation cleanup logic. Must not panic and must leave the
9132        // flag consistent with the most recent call.
9133        let session = SessionHandle::start(test_settings()).await.unwrap();
9134        let data = vec![0xAB; 16384];
9135        let meta = make_test_torrent(&data, 16384);
9136        let storage = make_storage(&data, 16384);
9137        let info_hash = session
9138            .add_torrent_with_meta(meta.into(), Some(storage))
9139            .await
9140            .unwrap();
9141
9142        for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
9143            session.set_seed_mode(info_hash, *enabled).await.unwrap();
9144            let stats = session.torrent_stats(info_hash).await.unwrap();
9145            assert_eq!(
9146                stats.user_seed_mode, *enabled,
9147                "iteration {i}: stats.user_seed_mode should track the toggle"
9148            );
9149        }
9150
9151        session.shutdown().await.unwrap();
9152    }
9153
9154    // ---- M159 Test: seed_mode_missing_info_hash_errors ----
9155
9156    #[tokio::test]
9157    async fn seed_mode_missing_info_hash_errors() {
9158        let session = SessionHandle::start(test_settings()).await.unwrap();
9159        let fake =
9160            irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
9161        let err = session
9162            .set_seed_mode(fake, true)
9163            .await
9164            .expect_err("set_seed_mode on unknown info hash must return an error");
9165        match err {
9166            crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9167            other => panic!("expected TorrentNotFound, got {other:?}"),
9168        }
9169        session.shutdown().await.unwrap();
9170    }
9171
9172    // ---- M159 Test: seed_mode_idempotent ----
9173
9174    #[tokio::test]
9175    async fn seed_mode_idempotent() {
9176        // Setting the same value twice must not panic or corrupt state.
9177        let session = SessionHandle::start(test_settings()).await.unwrap();
9178        let data = vec![0xAB; 16384];
9179        let meta = make_test_torrent(&data, 16384);
9180        let storage = make_storage(&data, 16384);
9181        let info_hash = session
9182            .add_torrent_with_meta(meta.into(), Some(storage))
9183            .await
9184            .unwrap();
9185
9186        // Set true twice — second call is a no-op.
9187        session.set_seed_mode(info_hash, true).await.unwrap();
9188        session.set_seed_mode(info_hash, true).await.unwrap();
9189        assert!(
9190            session
9191                .torrent_stats(info_hash)
9192                .await
9193                .unwrap()
9194                .user_seed_mode
9195        );
9196
9197        // Set false twice — also a no-op the second time.
9198        session.set_seed_mode(info_hash, false).await.unwrap();
9199        session.set_seed_mode(info_hash, false).await.unwrap();
9200        assert!(
9201            !session
9202                .torrent_stats(info_hash)
9203                .await
9204                .unwrap()
9205                .user_seed_mode
9206        );
9207
9208        session.shutdown().await.unwrap();
9209    }
9210
9211    // ---- Test: add_tracker_increases_count ----
9212
9213    #[tokio::test]
9214    async fn add_tracker_increases_count() {
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        // Test torrent has no trackers initially.
9225        let before = session.tracker_list(info_hash).await.unwrap();
9226        assert!(before.is_empty());
9227
9228        // Add a tracker.
9229        session
9230            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9231            .await
9232            .unwrap();
9233
9234        let after = session.tracker_list(info_hash).await.unwrap();
9235        assert_eq!(after.len(), 1);
9236        assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9237
9238        session.shutdown().await.unwrap();
9239    }
9240
9241    // ---- Test: replace_trackers_replaces_all ----
9242
9243    #[tokio::test]
9244    async fn replace_trackers_replaces_all() {
9245        let session = SessionHandle::start(test_settings()).await.unwrap();
9246        let data = vec![0xAB; 16384];
9247        let meta = make_test_torrent(&data, 16384);
9248        let storage = make_storage(&data, 16384);
9249        let info_hash = session
9250            .add_torrent_with_meta(meta.into(), Some(storage))
9251            .await
9252            .unwrap();
9253
9254        // Add 2 trackers.
9255        session
9256            .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9257            .await
9258            .unwrap();
9259        session
9260            .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9261            .await
9262            .unwrap();
9263        assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9264
9265        // Replace with 1 different tracker.
9266        session
9267            .replace_trackers(
9268                info_hash,
9269                vec!["http://replacement.example.com/announce".into()],
9270            )
9271            .await
9272            .unwrap();
9273
9274        let after = session.tracker_list(info_hash).await.unwrap();
9275        assert_eq!(after.len(), 1);
9276        assert_eq!(after[0].url, "http://replacement.example.com/announce");
9277
9278        session.shutdown().await.unwrap();
9279    }
9280
9281    // ---- Test: add_tracker_deduplicates ----
9282
9283    #[tokio::test]
9284    async fn add_tracker_deduplicates() {
9285        let session = SessionHandle::start(test_settings()).await.unwrap();
9286        let data = vec![0xAB; 16384];
9287        let meta = make_test_torrent(&data, 16384);
9288        let storage = make_storage(&data, 16384);
9289        let info_hash = session
9290            .add_torrent_with_meta(meta.into(), Some(storage))
9291            .await
9292            .unwrap();
9293
9294        // Add the same tracker URL twice.
9295        session
9296            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9297            .await
9298            .unwrap();
9299        session
9300            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9301            .await
9302            .unwrap();
9303
9304        // Should only have 1 tracker (deduplicated).
9305        let trackers = session.tracker_list(info_hash).await.unwrap();
9306        assert_eq!(trackers.len(), 1);
9307
9308        session.shutdown().await.unwrap();
9309    }
9310
9311    // ---- Test: info_hashes_matches_added_torrent ----
9312
9313    #[tokio::test]
9314    async fn info_hashes_matches_added_torrent() {
9315        let session = SessionHandle::start(test_settings()).await.unwrap();
9316        let data = vec![0xAB; 16384];
9317        let meta = make_test_torrent(&data, 16384);
9318        let expected_v1 = meta.info_hash;
9319        let storage = make_storage(&data, 16384);
9320
9321        let info_hash = session
9322            .add_torrent_with_meta(meta.into(), Some(storage))
9323            .await
9324            .unwrap();
9325        let hashes = session.info_hashes(info_hash).await.unwrap();
9326        assert_eq!(hashes.v1, Some(expected_v1));
9327        // v1-only torrent should not have v2 hash
9328        assert!(hashes.v2.is_none());
9329
9330        session.shutdown().await.unwrap();
9331    }
9332
9333    // ---- Test: torrent_file_returns_meta ----
9334
9335    #[tokio::test]
9336    async fn torrent_file_returns_meta() {
9337        let session = SessionHandle::start(test_settings()).await.unwrap();
9338        let data = vec![0xAB; 32768];
9339        let meta = make_test_torrent(&data, 16384);
9340        let storage = make_storage(&data, 16384);
9341
9342        let info_hash = session
9343            .add_torrent_with_meta(meta.into(), Some(storage))
9344            .await
9345            .unwrap();
9346        let torrent = session.torrent_file(info_hash).await.unwrap();
9347        assert!(torrent.is_some());
9348        let torrent = torrent.unwrap();
9349        assert_eq!(torrent.info_hash, info_hash);
9350        assert_eq!(torrent.info.name, "test");
9351        assert_eq!(torrent.info.total_length(), 32768);
9352
9353        session.shutdown().await.unwrap();
9354    }
9355
9356    // ---- Test: torrent_file_none_before_metadata ----
9357
9358    #[tokio::test]
9359    async fn torrent_file_none_before_metadata() {
9360        let session = SessionHandle::start(test_settings()).await.unwrap();
9361        let magnet = irontide_core::Magnet::parse(
9362            "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9363        )
9364        .unwrap();
9365
9366        let info_hash = session.add_magnet(magnet).await.unwrap();
9367        let torrent = session.torrent_file(info_hash).await.unwrap();
9368        // Before metadata is received, torrent_file should return None.
9369        assert!(torrent.is_none());
9370
9371        session.shutdown().await.unwrap();
9372    }
9373
9374    // ---- Test: force_dht_announce_no_error ----
9375
9376    #[tokio::test]
9377    async fn force_dht_announce_no_error() {
9378        let session = SessionHandle::start(test_settings()).await.unwrap();
9379        let data = vec![0xAB; 16384];
9380        let meta = make_test_torrent(&data, 16384);
9381        let storage = make_storage(&data, 16384);
9382        let info_hash = session
9383            .add_torrent_with_meta(meta.into(), Some(storage))
9384            .await
9385            .unwrap();
9386
9387        // Should succeed even without DHT enabled (no-op, no error).
9388        let result = session.force_dht_announce(info_hash).await;
9389        assert!(
9390            result.is_ok(),
9391            "force_dht_announce should succeed: {result:?}"
9392        );
9393
9394        // Should fail for unknown torrent.
9395        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9396        assert!(session.force_dht_announce(fake).await.is_err());
9397
9398        session.shutdown().await.unwrap();
9399    }
9400
9401    // ---- Test: force_lsd_announce_no_error ----
9402
9403    #[tokio::test]
9404    async fn force_lsd_announce_no_error() {
9405        let session = SessionHandle::start(test_settings()).await.unwrap();
9406        let data = vec![0xAB; 16384];
9407        let meta = make_test_torrent(&data, 16384);
9408        let storage = make_storage(&data, 16384);
9409        let info_hash = session
9410            .add_torrent_with_meta(meta.into(), Some(storage))
9411            .await
9412            .unwrap();
9413
9414        // Should succeed even without LSD enabled (no-op announce, no error).
9415        let result = session.force_lsd_announce(info_hash).await;
9416        assert!(
9417            result.is_ok(),
9418            "force_lsd_announce should succeed: {result:?}"
9419        );
9420
9421        // Should fail for unknown torrent.
9422        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9423        assert!(session.force_lsd_announce(fake).await.is_err());
9424
9425        session.shutdown().await.unwrap();
9426    }
9427
9428    // ---- Test: read_piece_after_download ----
9429
9430    #[tokio::test]
9431    async fn read_piece_after_download() {
9432        let data = vec![0xCD; 32768]; // 2 pieces of 16384
9433        let meta = make_test_torrent(&data, 16384);
9434        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9435        let storage = Arc::new(MemoryStorage::new(lengths));
9436        // Pre-fill storage with the data
9437        storage.write_chunk(0, 0, &data[..16384]).unwrap();
9438        storage.write_chunk(1, 0, &data[16384..]).unwrap();
9439
9440        let session = SessionHandle::start(test_settings()).await.unwrap();
9441        let info_hash = session
9442            .add_torrent_with_meta(meta.into(), Some(storage))
9443            .await
9444            .unwrap();
9445
9446        // Read piece 0
9447        let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9448        assert_eq!(piece_data.len(), 16384);
9449        assert!(piece_data.iter().all(|&b| b == 0xCD));
9450
9451        // Read piece 1
9452        let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9453        assert_eq!(piece_data.len(), 16384);
9454        assert!(piece_data.iter().all(|&b| b == 0xCD));
9455
9456        // Out-of-range piece should fail
9457        let result = session.read_piece(info_hash, 999).await;
9458        assert!(result.is_err(), "read_piece out of range should fail");
9459
9460        // Unknown torrent should fail
9461        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9462        assert!(session.read_piece(fake, 0).await.is_err());
9463
9464        session.shutdown().await.unwrap();
9465    }
9466
9467    // ---- Test: flush_cache_completes ----
9468
9469    #[tokio::test]
9470    async fn flush_cache_completes() {
9471        let session = SessionHandle::start(test_settings()).await.unwrap();
9472        let data = vec![0xAB; 16384];
9473        let meta = make_test_torrent(&data, 16384);
9474        let storage = make_storage(&data, 16384);
9475        let info_hash = session
9476            .add_torrent_with_meta(meta.into(), Some(storage))
9477            .await
9478            .unwrap();
9479
9480        // flush_cache should succeed.
9481        let result = session.flush_cache(info_hash).await;
9482        assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9483
9484        // Should fail for unknown torrent.
9485        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9486        assert!(session.flush_cache(fake).await.is_err());
9487
9488        session.shutdown().await.unwrap();
9489    }
9490
9491    // ---- BEP 44 session API tests ----
9492
9493    fn test_settings_with_dht() -> Settings {
9494        let mut s = test_settings();
9495        s.enable_dht = true;
9496        s
9497    }
9498
9499    fn test_settings_with_lsd() -> Settings {
9500        let mut s = test_settings();
9501        s.enable_lsd = true;
9502        s
9503    }
9504
9505    #[tokio::test]
9506    async fn test_dht_disabled_returns_error() {
9507        let session = SessionHandle::start(test_settings()).await.unwrap();
9508
9509        // All 4 methods should fail with DhtDisabled when DHT is off
9510        let err = session
9511            .dht_put_immutable(b"test".to_vec())
9512            .await
9513            .unwrap_err();
9514        assert!(
9515            format!("{err:?}").contains("DhtDisabled"),
9516            "expected DhtDisabled, got {err:?}"
9517        );
9518
9519        let target = Id20::from([0u8; 20]);
9520        let err = session.dht_get_immutable(target).await.unwrap_err();
9521        assert!(
9522            format!("{err:?}").contains("DhtDisabled"),
9523            "expected DhtDisabled, got {err:?}"
9524        );
9525
9526        let err = session
9527            .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
9528            .await
9529            .unwrap_err();
9530        assert!(
9531            format!("{err:?}").contains("DhtDisabled"),
9532            "expected DhtDisabled, got {err:?}"
9533        );
9534
9535        let err = session
9536            .dht_get_mutable([42u8; 32], Vec::new())
9537            .await
9538            .unwrap_err();
9539        assert!(
9540            format!("{err:?}").contains("DhtDisabled"),
9541            "expected DhtDisabled, got {err:?}"
9542        );
9543
9544        session.shutdown().await.unwrap();
9545    }
9546
9547    #[tokio::test]
9548    async fn test_dht_put_get_immutable_round_trip() {
9549        let session = SessionHandle::start(test_settings_with_dht())
9550            .await
9551            .unwrap();
9552
9553        // Give DHT a moment to bootstrap (it won't find real nodes, but the handle should work)
9554        let value = b"hello BEP 44".to_vec();
9555        let target = session.dht_put_immutable(value.clone()).await.unwrap();
9556
9557        // The target should be the SHA-1 of the bencoded value
9558        // Try to get it back — since we're the only node, the local store should have it
9559        let got = session.dht_get_immutable(target).await.unwrap();
9560        assert_eq!(got, Some(value));
9561
9562        session.shutdown().await.unwrap();
9563    }
9564
9565    #[tokio::test]
9566    async fn test_dht_put_immutable_fires_alert() {
9567        use crate::alert::{AlertCategory, AlertKind};
9568
9569        let session = SessionHandle::start(test_settings_with_dht())
9570            .await
9571            .unwrap();
9572        let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
9573
9574        let value = b"alert test".to_vec();
9575        let target = session.dht_put_immutable(value).await.unwrap();
9576
9577        // Should receive DhtPutComplete alert
9578        let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
9579            .await
9580            .expect("timeout waiting for alert")
9581            .expect("alert channel closed");
9582
9583        match alert.kind {
9584            AlertKind::DhtPutComplete { target: t } => {
9585                assert_eq!(t, target);
9586            }
9587            other => panic!("expected DhtPutComplete, got {other:?}"),
9588        }
9589
9590        session.shutdown().await.unwrap();
9591    }
9592
9593    // ---- BEP 27: Private torrent LSD tests ----
9594
9595    /// Creates a private torrent (.torrent bytes with private=1 in the info dict).
9596    fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
9597        use serde::Serialize;
9598
9599        #[derive(Serialize)]
9600        struct Info<'a> {
9601            length: u64,
9602            name: &'a str,
9603            #[serde(rename = "piece length")]
9604            piece_length: u64,
9605            #[serde(with = "serde_bytes")]
9606            pieces: &'a [u8],
9607            private: i64,
9608        }
9609
9610        #[derive(Serialize)]
9611        struct Torrent<'a> {
9612            info: Info<'a>,
9613        }
9614
9615        let mut pieces = Vec::new();
9616        let mut offset = 0;
9617        while offset < data.len() {
9618            let end = (offset + piece_length as usize).min(data.len());
9619            let hash = irontide_core::sha1(&data[offset..end]);
9620            pieces.extend_from_slice(hash.as_bytes());
9621            offset = end;
9622        }
9623
9624        let t = Torrent {
9625            info: Info {
9626                length: data.len() as u64,
9627                name: "private-test",
9628                piece_length,
9629                pieces: &pieces,
9630                private: 1,
9631            },
9632        };
9633
9634        let bytes = irontide_bencode::to_bytes(&t).unwrap();
9635        torrent_from_bytes(&bytes).unwrap()
9636    }
9637
9638    #[test]
9639    fn is_private_true_via_parsed_meta() {
9640        // Verify that a torrent parsed from private .torrent bytes has private == Some(1)
9641        let data = vec![0xAB; 16384];
9642        let meta = make_private_torrent(&data, 16384);
9643        assert_eq!(
9644            meta.info.private,
9645            Some(1),
9646            "private field should be Some(1)"
9647        );
9648    }
9649
9650    #[test]
9651    fn is_private_false_for_public_torrent() {
9652        // Verify that a regular torrent has private == None
9653        let data = vec![0xAB; 16384];
9654        let meta = make_test_torrent(&data, 16384);
9655        assert_eq!(
9656            meta.info.private, None,
9657            "public torrent should have no private flag"
9658        );
9659    }
9660
9661    #[test]
9662    fn private_torrent_config_disables_lsd() {
9663        // Verify that TorrentConfig::default() has LSD enabled (so disable is meaningful)
9664        let config = TorrentConfig::default();
9665        assert!(
9666            config.enable_lsd,
9667            "default TorrentConfig should have LSD enabled"
9668        );
9669    }
9670
9671    #[tokio::test]
9672    async fn force_lsd_announce_private_torrent_returns_error() {
9673        let session = SessionHandle::start(test_settings()).await.unwrap();
9674        let data = vec![0xAB; 16384];
9675        let meta = make_private_torrent(&data, 16384);
9676        let storage = make_storage(&data, 16384);
9677        let info_hash = session
9678            .add_torrent_with_meta(meta.into(), Some(storage))
9679            .await
9680            .unwrap();
9681
9682        // BEP 27: force_lsd_announce on a private torrent must return an error
9683        let result = session.force_lsd_announce(info_hash).await;
9684        assert!(
9685            result.is_err(),
9686            "force_lsd_announce on private torrent should return error, got: {result:?}"
9687        );
9688        let err_str = format!("{:?}", result.unwrap_err());
9689        assert!(
9690            err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
9691            "expected InvalidSettings error, got: {err_str}"
9692        );
9693
9694        session.shutdown().await.unwrap();
9695    }
9696
9697    #[tokio::test]
9698    async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
9699        // v0.173.2 A10 companion to the DHT negative-form test added in T1.
9700        //
9701        // NEGATIVE form (codex finding #2): we only assert the BEP 27 gate doesn't
9702        // reject. We do NOT assert Ok — LSD may return Ok for a different reason
9703        // (e.g., self.lsd is None per session.rs:945's startup-failure swallow),
9704        // and asserting Ok would mask that case while still passing. We also
9705        // don't assert a specific Err — public torrents on an enabled-LSD session
9706        // typically return Ok, but a test-sandbox LSD init glitch could legitimately
9707        // error without the BEP 27 gate being involved.
9708        let session = SessionHandle::start(test_settings_with_lsd())
9709            .await
9710            .unwrap();
9711        let data = vec![0xAB; 16384];
9712        let meta = make_test_torrent(&data, 16384);
9713        let storage = make_storage(&data, 16384);
9714        let info_hash = session
9715            .add_torrent_with_meta(meta.into(), Some(storage))
9716            .await
9717            .unwrap();
9718
9719        let result = session.force_lsd_announce(info_hash).await;
9720        if let Err(e) = &result {
9721            assert!(
9722                !format!("{e:?}").contains("LSD disabled for private torrent"),
9723                "public torrent must NOT trigger BEP 27 error; got {e:?}"
9724            );
9725        }
9726
9727        session.shutdown().await.unwrap();
9728    }
9729
9730    #[tokio::test]
9731    async fn force_dht_announce_private_torrent_returns_error() {
9732        let session = SessionHandle::start(test_settings_with_dht())
9733            .await
9734            .unwrap();
9735        let data = vec![0xAB; 16384];
9736        let meta = make_private_torrent(&data, 16384);
9737        let storage = make_storage(&data, 16384);
9738        let info_hash = session
9739            .add_torrent_with_meta(meta.into(), Some(storage))
9740            .await
9741            .unwrap();
9742
9743        // BEP 27: force_dht_announce on a private torrent must return an error
9744        let result = session.force_dht_announce(info_hash).await;
9745        assert!(
9746            result.is_err(),
9747            "force_dht_announce on private torrent should return error, got: {result:?}"
9748        );
9749        let err_str = format!("{:?}", result.unwrap_err());
9750        assert!(
9751            err_str.contains("InvalidSettings")
9752                || err_str.contains("DHT disabled for private torrent"),
9753            "expected InvalidSettings / DHT-disabled error, got: {err_str}"
9754        );
9755
9756        session.shutdown().await.unwrap();
9757    }
9758
9759    #[tokio::test]
9760    async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
9761        let session = SessionHandle::start(test_settings_with_dht())
9762            .await
9763            .unwrap();
9764        let data = vec![0xAB; 16384];
9765        let meta = make_test_torrent(&data, 16384);
9766        let storage = make_storage(&data, 16384);
9767        let info_hash = session
9768            .add_torrent_with_meta(meta.into(), Some(storage))
9769            .await
9770            .unwrap();
9771
9772        let result = session.force_dht_announce(info_hash).await;
9773        // NEGATIVE form: we only assert the BEP 27 gate doesn't reject. We do NOT
9774        // assert Ok — DHT may not be initialised in the test sandbox, returning a
9775        // different error. Asserting Ok here would mask both the test sandbox
9776        // limitation AND a future regression where the BEP 27 gate accidentally
9777        // catches public torrents.
9778        if let Err(e) = &result {
9779            assert!(
9780                !format!("{e:?}").contains("DHT disabled for private torrent"),
9781                "public torrent must NOT trigger BEP 27 error; got {e:?}"
9782            );
9783        }
9784
9785        session.shutdown().await.unwrap();
9786    }
9787
9788    // ---- M161: save_resume_state tests ----
9789
9790    fn resume_test_settings(dir: &std::path::Path) -> Settings {
9791        Settings {
9792            resume_data_dir: Some(dir.to_path_buf()),
9793            save_resume_interval_secs: 0, // disable periodic timer for tests
9794            ..test_settings()
9795        }
9796    }
9797
9798    #[tokio::test]
9799    async fn save_resume_state_empty_session_returns_zero() {
9800        let tmp = tempfile::TempDir::new().unwrap();
9801        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9802            .await
9803            .unwrap();
9804
9805        let count = session.save_resume_state().await.unwrap();
9806        assert_eq!(count, 0, "empty session should save 0 resume files");
9807
9808        session.shutdown().await.unwrap();
9809    }
9810
9811    #[tokio::test]
9812    async fn save_resume_state_saves_dirty_torrents() {
9813        let tmp = tempfile::TempDir::new().unwrap();
9814        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9815            .await
9816            .unwrap();
9817
9818        // Add two torrents with different data so they get different hashes
9819        let data1 = vec![0xAA; 16384];
9820        let meta1 = make_test_torrent(&data1, 16384);
9821        let hash1 = meta1.info_hash;
9822        let storage1 = make_storage(&data1, 16384);
9823        session
9824            .add_torrent_with_meta(meta1.into(), Some(storage1))
9825            .await
9826            .unwrap();
9827
9828        let data2 = vec![0xBB; 16384];
9829        let meta2 = make_test_torrent(&data2, 16384);
9830        let hash2 = meta2.info_hash;
9831        let storage2 = make_storage(&data2, 16384);
9832        session
9833            .add_torrent_with_meta(meta2.into(), Some(storage2))
9834            .await
9835            .unwrap();
9836
9837        // Both torrents should be dirty (newly added → need_save_resume = true
9838        // after any state change). Give the actors a moment to settle.
9839        tokio::time::sleep(Duration::from_millis(50)).await;
9840
9841        let count = session.save_resume_state().await.unwrap();
9842        // Newly created torrents may or may not have the dirty flag set
9843        // depending on whether state changes have occurred. Verify that
9844        // at least the files are created for any that were dirty.
9845        assert!(count <= 2, "should save at most 2 resume files");
9846
9847        // Verify files exist on disk for saved torrents
9848        let torrents_dir = tmp.path().join("torrents");
9849        if count > 0 {
9850            assert!(torrents_dir.exists(), "torrents/ directory should exist");
9851        }
9852
9853        // Check specific file paths
9854        let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
9855        let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
9856        let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
9857        assert_eq!(
9858            files_exist, count,
9859            "number of files on disk should match returned count"
9860        );
9861
9862        session.shutdown().await.unwrap();
9863    }
9864
9865    #[tokio::test]
9866    async fn save_resume_state_round_trip() {
9867        let tmp = tempfile::TempDir::new().unwrap();
9868        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9869            .await
9870            .unwrap();
9871
9872        let data = vec![0xCD; 32768];
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        // Let the actor settle so dirty flag is set
9882        tokio::time::sleep(Duration::from_millis(50)).await;
9883
9884        let count = session.save_resume_state().await.unwrap();
9885
9886        // If the torrent was dirty and saved, verify round-trip
9887        if count > 0 {
9888            let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
9889            assert!(path.exists(), "resume file should exist after save");
9890
9891            let bytes = std::fs::read(&path).unwrap();
9892            let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
9893            assert_eq!(
9894                rd.info_hash,
9895                info_hash.as_bytes().to_vec(),
9896                "deserialized info_hash should match"
9897            );
9898            assert_eq!(rd.name, "test", "deserialized name should match");
9899        }
9900
9901        session.shutdown().await.unwrap();
9902    }
9903
9904    #[tokio::test]
9905    async fn save_resume_state_clears_dirty_flag() {
9906        let tmp = tempfile::TempDir::new().unwrap();
9907        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9908            .await
9909            .unwrap();
9910
9911        let data = vec![0xEE; 16384];
9912        let meta = make_test_torrent(&data, 16384);
9913        let storage = make_storage(&data, 16384);
9914        session
9915            .add_torrent_with_meta(meta.into(), Some(storage))
9916            .await
9917            .unwrap();
9918
9919        tokio::time::sleep(Duration::from_millis(50)).await;
9920
9921        let first_count = session.save_resume_state().await.unwrap();
9922
9923        // Second save should return 0 because dirty flag was cleared
9924        let second_count = session.save_resume_state().await.unwrap();
9925        assert_eq!(
9926            second_count, 0,
9927            "second save should return 0 after dirty flag cleared (first saved {first_count})"
9928        );
9929
9930        session.shutdown().await.unwrap();
9931    }
9932
9933    #[tokio::test]
9934    async fn save_resume_state_second_save_skips_clean() {
9935        let tmp = tempfile::TempDir::new().unwrap();
9936        let session = SessionHandle::start(resume_test_settings(tmp.path()))
9937            .await
9938            .unwrap();
9939
9940        let data1 = vec![0xAA; 16384];
9941        let meta1 = make_test_torrent(&data1, 16384);
9942        let storage1 = make_storage(&data1, 16384);
9943        session
9944            .add_torrent_with_meta(meta1.into(), Some(storage1))
9945            .await
9946            .unwrap();
9947
9948        let data2 = vec![0xBB; 16384];
9949        let meta2 = make_test_torrent(&data2, 16384);
9950        let storage2 = make_storage(&data2, 16384);
9951        session
9952            .add_torrent_with_meta(meta2.into(), Some(storage2))
9953            .await
9954            .unwrap();
9955
9956        tokio::time::sleep(Duration::from_millis(50)).await;
9957
9958        // First save
9959        let first = session.save_resume_state().await.unwrap();
9960
9961        // Second save — all flags should be cleared, nothing to write
9962        let second = session.save_resume_state().await.unwrap();
9963        assert_eq!(
9964            second, 0,
9965            "second save should skip all clean torrents (first saved {first})"
9966        );
9967
9968        session.shutdown().await.unwrap();
9969    }
9970
9971    // ==== M161 Phase 4: load_resume_state tests ====
9972
9973    // ---- Test: empty dir → zeros ----
9974
9975    #[tokio::test]
9976    async fn load_resume_empty_dir_returns_zeros() {
9977        let tmp = tempfile::TempDir::new().unwrap();
9978        let mut settings = test_settings();
9979        settings.resume_data_dir = Some(tmp.path().to_path_buf());
9980
9981        let session = SessionHandle::start(settings).await.unwrap();
9982        let result = session.load_resume_state().await.unwrap();
9983        assert_eq!(result.restored, 0);
9984        assert_eq!(result.skipped, 0);
9985        assert_eq!(result.failed, 0);
9986
9987        session.shutdown().await.unwrap();
9988    }
9989
9990    // ---- Test: corrupt file skipped ----
9991
9992    #[tokio::test]
9993    async fn load_resume_corrupt_file_counted_as_failed() {
9994        let tmp = tempfile::TempDir::new().unwrap();
9995        let torrents_dir = tmp.path().join("torrents");
9996        std::fs::create_dir_all(&torrents_dir).unwrap();
9997
9998        let mut settings = test_settings();
9999        settings.resume_data_dir = Some(tmp.path().to_path_buf());
10000
10001        // Start session first (auto-restore runs but dir is empty).
10002        let session = SessionHandle::start(settings).await.unwrap();
10003
10004        // Wait for auto-restore to complete before writing the file,
10005        // otherwise the actor may race with file creation.
10006        tokio::time::sleep(Duration::from_millis(50)).await;
10007
10008        // Write garbage to a .resume file *after* startup so auto-restore
10009        // does not consume it.
10010        std::fs::write(
10011            torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
10012            b"this is not valid bencode",
10013        )
10014        .unwrap();
10015
10016        let result = session.load_resume_state().await.unwrap();
10017        assert_eq!(result.restored, 0);
10018        assert_eq!(result.skipped, 0);
10019        assert_eq!(result.failed, 1);
10020
10021        session.shutdown().await.unwrap();
10022    }
10023
10024    // ---- Test: duplicate torrent skipped ----
10025
10026    #[tokio::test]
10027    async fn load_resume_duplicate_skipped() {
10028        let tmp = tempfile::TempDir::new().unwrap();
10029        let mut settings = test_settings();
10030        settings.resume_data_dir = Some(tmp.path().to_path_buf());
10031
10032        let session = SessionHandle::start(settings).await.unwrap();
10033
10034        // Add a torrent first.
10035        let data = vec![0xAB; 16384];
10036        let meta = make_test_torrent(&data, 16384);
10037        let info_hash = meta.info_hash;
10038        let storage = make_storage(&data, 16384);
10039        session
10040            .add_torrent_with_meta(meta.into(), Some(storage))
10041            .await
10042            .unwrap();
10043
10044        // Wait for the torrent to settle.
10045        tokio::time::sleep(Duration::from_millis(50)).await;
10046
10047        // Save resume state so we have a file on disk.
10048        let _ = session.save_resume_state().await;
10049
10050        // Load again — the torrent already exists so it should be skipped.
10051        let result = session.load_resume_state().await.unwrap();
10052        assert!(
10053            session.list_torrents().await.unwrap().contains(&info_hash),
10054            "original torrent should still exist"
10055        );
10056        assert_eq!(result.skipped, 1, "duplicate should be skipped");
10057        assert_eq!(result.failed, 0);
10058
10059        session.shutdown().await.unwrap();
10060    }
10061
10062    // ---- Test: reconstruct_torrent_meta with info present ----
10063
10064    #[test]
10065    fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
10066        use crate::resume_file::reconstruct_torrent_meta;
10067        use irontide_core::FastResumeData;
10068
10069        let data = vec![0xAB; 16384];
10070        let meta = make_test_torrent(&data, 16384);
10071        let info_hash = meta.info_hash;
10072
10073        // Create resume data with a stored info dict.
10074        let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
10075        let mut rd = FastResumeData::new(
10076            info_hash.as_bytes().to_vec(),
10077            "test-torrent".into(),
10078            "/downloads".into(),
10079        );
10080        rd.info = Some(info_bytes);
10081        rd.trackers = vec![
10082            vec!["http://tracker1.example.com/announce".into()],
10083            vec!["http://tracker2.example.com/announce".into()],
10084        ];
10085        rd.url_seeds = vec!["http://seed.example.com/".into()];
10086        rd.http_seeds = vec!["http://httpseed.example.com/".into()];
10087
10088        let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
10089
10090        assert_eq!(reconstructed.info_hash, info_hash);
10091        assert_eq!(
10092            reconstructed.announce.as_deref(),
10093            Some("http://tracker1.example.com/announce")
10094        );
10095        assert!(reconstructed.announce_list.is_some());
10096        assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
10097        assert_eq!(
10098            reconstructed.url_list,
10099            vec!["http://seed.example.com/".to_string()]
10100        );
10101        assert_eq!(
10102            reconstructed.httpseeds,
10103            vec!["http://httpseed.example.com/".to_string()]
10104        );
10105        assert!(reconstructed.info_bytes.is_some());
10106        assert!(reconstructed.comment.is_none());
10107        assert!(reconstructed.created_by.is_none());
10108        assert!(reconstructed.creation_date.is_none());
10109    }
10110
10111    // ---- Test: reconstruct_torrent_meta returns None for unresolved magnet ----
10112
10113    #[test]
10114    fn reconstruct_torrent_meta_returns_none_without_info() {
10115        use crate::resume_file::reconstruct_torrent_meta;
10116        use irontide_core::FastResumeData;
10117
10118        let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
10119        // info is None by default — simulates unresolved magnet.
10120        assert!(rd.info.is_none());
10121        assert!(reconstruct_torrent_meta(&rd).is_none());
10122    }
10123
10124    // ---- Test: reconstruct_magnet returns Some ----
10125
10126    #[test]
10127    fn reconstruct_magnet_returns_some_with_correct_fields() {
10128        use crate::resume_file::reconstruct_magnet;
10129        use irontide_core::FastResumeData;
10130
10131        let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
10132        rd.trackers = vec![
10133            vec!["http://tracker1.com/announce".into()],
10134            vec![
10135                "http://tracker2.com/announce".into(),
10136                "http://tracker3.com/announce".into(),
10137            ],
10138        ];
10139
10140        let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
10141
10142        assert!(magnet.info_hashes.v1.is_some());
10143        assert!(magnet.info_hashes.v2.is_none());
10144        assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
10145        // Trackers flattened: 3 total from 2 tiers.
10146        assert_eq!(magnet.trackers.len(), 3);
10147        assert!(magnet.peers.is_empty());
10148        assert!(magnet.selected_files.is_none());
10149    }
10150
10151    // ---- Test: reconstruct_magnet with info_hash2 preserved ----
10152
10153    #[test]
10154    fn reconstruct_magnet_preserves_info_hash2() {
10155        use crate::resume_file::reconstruct_magnet;
10156        use irontide_core::FastResumeData;
10157
10158        let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
10159        rd.info_hash2 = Some(vec![0xEE; 32]);
10160
10161        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10162        assert!(magnet.info_hashes.v1.is_some());
10163        assert!(magnet.info_hashes.v2.is_some());
10164
10165        let v2 = magnet.info_hashes.v2.unwrap();
10166        assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10167    }
10168
10169    // ---- Test: reconstruct_magnet with empty name ----
10170
10171    #[test]
10172    fn reconstruct_magnet_empty_name_is_none() {
10173        use crate::resume_file::reconstruct_magnet;
10174        use irontide_core::FastResumeData;
10175
10176        let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10177        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10178        assert!(
10179            magnet.display_name.is_none(),
10180            "empty name should map to None"
10181        );
10182    }
10183
10184    // ==== M161 Phase 5: auto-save / auto-restore / orphan cleanup ====
10185
10186    // ---- Test: shutdown writes resume files ----
10187
10188    #[tokio::test]
10189    async fn shutdown_saves_resume_files() {
10190        let tmp = tempfile::TempDir::new().unwrap();
10191        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10192            .await
10193            .unwrap();
10194
10195        let data = vec![0xAB; 16384];
10196        let meta = make_test_torrent(&data, 16384);
10197        let info_hash = meta.info_hash;
10198        let storage = make_storage(&data, 16384);
10199        session
10200            .add_torrent_with_meta(meta.into(), Some(storage))
10201            .await
10202            .unwrap();
10203
10204        // Force a state change to set the dirty flag: pause then resume.
10205        session.pause_torrent(info_hash).await.unwrap();
10206        tokio::time::sleep(Duration::from_millis(50)).await;
10207        session.resume_torrent(info_hash).await.unwrap();
10208        tokio::time::sleep(Duration::from_millis(50)).await;
10209
10210        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10211
10212        // Shutdown triggers save_dirty_resume_files internally.
10213        // SessionHandle::shutdown() is fire-and-forget, so we need to
10214        // wait briefly for the actor to finish writing to disk.
10215        session.shutdown().await.unwrap();
10216        tokio::time::sleep(Duration::from_millis(200)).await;
10217
10218        assert!(path.exists(), "resume file should exist after shutdown");
10219    }
10220
10221    // ---- Test: auto-restore on startup ----
10222
10223    #[tokio::test]
10224    async fn auto_restore_on_startup() {
10225        let tmp = tempfile::TempDir::new().unwrap();
10226
10227        let info_hash;
10228        {
10229            // First session: add a torrent, save, and shut down.
10230            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10231                .await
10232                .unwrap();
10233
10234            let data = vec![0xAB; 16384];
10235            let meta = make_test_torrent(&data, 16384);
10236            info_hash = meta.info_hash;
10237            let storage = make_storage(&data, 16384);
10238            session
10239                .add_torrent_with_meta(meta.into(), Some(storage))
10240                .await
10241                .unwrap();
10242
10243            tokio::time::sleep(Duration::from_millis(50)).await;
10244            let _ = session.save_resume_state().await;
10245            session.shutdown().await.unwrap();
10246        }
10247
10248        // Verify the resume file exists before starting a new session.
10249        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10250        assert!(path.exists(), "resume file should exist before restart");
10251
10252        {
10253            // Second session: should auto-restore the torrent on startup.
10254            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10255                .await
10256                .unwrap();
10257
10258            // Give the actor a moment to process the auto-restore.
10259            tokio::time::sleep(Duration::from_millis(100)).await;
10260
10261            let list = session.list_torrents().await.unwrap();
10262            assert!(
10263                list.contains(&info_hash),
10264                "torrent should be auto-restored on startup"
10265            );
10266
10267            session.shutdown().await.unwrap();
10268        }
10269    }
10270
10271    // ---- Test: shutdown with read-only resume dir completes without error ----
10272
10273    #[tokio::test]
10274    async fn shutdown_with_readonly_resume_dir_completes() {
10275        let tmp = tempfile::TempDir::new().unwrap();
10276        // Point resume_data_dir to a non-existent path under a read-only root.
10277        // On Linux, /proc is always read-only for directory creation.
10278        let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10279        let mut settings = test_settings();
10280        settings.resume_data_dir = Some(readonly_dir);
10281
10282        let session = SessionHandle::start(settings).await.unwrap();
10283
10284        let data = vec![0xAB; 16384];
10285        let meta = make_test_torrent(&data, 16384);
10286        let storage = make_storage(&data, 16384);
10287        session
10288            .add_torrent_with_meta(meta.into(), Some(storage))
10289            .await
10290            .unwrap();
10291
10292        tokio::time::sleep(Duration::from_millis(50)).await;
10293
10294        // Shutdown should complete without panic or error even though
10295        // the resume dir is not writable.
10296        session.shutdown().await.unwrap();
10297
10298        // If we got here, the test passed — errors were logged, not propagated.
10299        drop(tmp);
10300    }
10301
10302    // ---- Test: orphan resume file deleted on startup ----
10303
10304    #[tokio::test]
10305    async fn orphan_resume_file_deleted_on_startup() {
10306        let tmp = tempfile::TempDir::new().unwrap();
10307        let torrents_dir = tmp.path().join("torrents");
10308        std::fs::create_dir_all(&torrents_dir).unwrap();
10309
10310        // Write a fake .resume file that does not match any torrent.
10311        // Use valid bencode so it parses but with a hash that won't match
10312        // anything added to the session. The file must parse correctly for
10313        // the load to attempt adding it (which will fail or produce a torrent
10314        // with a mismatched hash that gets cleaned up as orphan).
10315        // Simplest: write garbage bencode — it will fail to deserialize,
10316        // not be added, and then orphan cleanup should remove it.
10317        let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10318        std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10319        assert!(orphan_path.exists(), "orphan file should exist before test");
10320
10321        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10322            .await
10323            .unwrap();
10324
10325        // Give the actor time to auto-restore + orphan cleanup.
10326        tokio::time::sleep(Duration::from_millis(100)).await;
10327
10328        assert!(
10329            !orphan_path.exists(),
10330            "orphan resume file should be deleted on startup"
10331        );
10332
10333        session.shutdown().await.unwrap();
10334    }
10335
10336    // ==== M161 Phase 7: integration tests for resume file lifecycle ====
10337
10338    // ---- Test: multi-torrent save-load round-trip ----
10339    //
10340    // Creates 3 torrents in session 1, saves resume state, verifies 3 `.resume`
10341    // files on disk. Starts session 2 with the same resume dir and verifies all
10342    // 3 torrents are restored via `load_resume_state()`.
10343
10344    #[tokio::test]
10345    async fn multi_torrent_save_load_round_trip() {
10346        let tmp = tempfile::TempDir::new().unwrap();
10347
10348        // Distinct data per torrent to produce unique info hashes.
10349        let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10350        let mut hashes = Vec::with_capacity(3);
10351
10352        {
10353            // Session 1: add 3 torrents, save resume state.
10354            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10355                .await
10356                .unwrap();
10357
10358            for &byte in &datasets {
10359                let data = vec![byte; 16384];
10360                let meta = make_test_torrent(&data, 16384);
10361                let info_hash = meta.info_hash;
10362                let storage = make_storage(&data, 16384);
10363                session
10364                    .add_torrent_with_meta(meta.into(), Some(storage))
10365                    .await
10366                    .unwrap();
10367                hashes.push(info_hash);
10368            }
10369
10370            // Let actors settle so dirty flags are set.
10371            tokio::time::sleep(Duration::from_millis(100)).await;
10372
10373            let saved = session.save_resume_state().await.unwrap();
10374            assert_eq!(saved, 3, "all 3 torrents should be saved");
10375
10376            // Verify .resume files exist on disk.
10377            let files = crate::resume_file::scan_resume_dir(tmp.path());
10378            assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10379
10380            for hash in &hashes {
10381                let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10382                assert!(
10383                    path.exists(),
10384                    "resume file for {} should exist",
10385                    hex::encode(hash.as_bytes())
10386                );
10387            }
10388
10389            session.shutdown().await.unwrap();
10390        }
10391
10392        {
10393            // Session 2: fresh session with the same resume dir.
10394            // Disable auto-restore by starting first, then calling
10395            // load_resume_state manually.
10396            //
10397            // NOTE: the auto-restore runs during `start()` before we get the
10398            // handle back, so the torrents will already be loaded. Use
10399            // list_torrents to verify instead.
10400            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10401                .await
10402                .unwrap();
10403
10404            // Give the actor time to process auto-restore.
10405            tokio::time::sleep(Duration::from_millis(200)).await;
10406
10407            let list = session.list_torrents().await.unwrap();
10408            assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10409
10410            for hash in &hashes {
10411                assert!(
10412                    list.contains(hash),
10413                    "torrent {} should be present after restore",
10414                    hex::encode(hash.as_bytes())
10415                );
10416            }
10417
10418            session.shutdown().await.unwrap();
10419        }
10420    }
10421
10422    // ---- Test: corrupt 1 of 3 resume files → 2 restored + 1 failed ----
10423    //
10424    // Saves 3 torrents to resume files, corrupts one with garbage bytes,
10425    // then starts a fresh session and verifies that 2 are restored and 1 failed.
10426
10427    #[tokio::test]
10428    async fn corrupt_one_of_three_resume_files() {
10429        let tmp = tempfile::TempDir::new().unwrap();
10430
10431        let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10432        let mut hashes = Vec::with_capacity(3);
10433
10434        {
10435            // Session 1: add 3 torrents, save resume state.
10436            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10437                .await
10438                .unwrap();
10439
10440            for &byte in &datasets {
10441                let data = vec![byte; 16384];
10442                let meta = make_test_torrent(&data, 16384);
10443                let info_hash = meta.info_hash;
10444                let storage = make_storage(&data, 16384);
10445                session
10446                    .add_torrent_with_meta(meta.into(), Some(storage))
10447                    .await
10448                    .unwrap();
10449                hashes.push(info_hash);
10450            }
10451
10452            tokio::time::sleep(Duration::from_millis(100)).await;
10453
10454            let saved = session.save_resume_state().await.unwrap();
10455            assert_eq!(saved, 3, "all 3 torrents should be saved");
10456
10457            session.shutdown().await.unwrap();
10458        }
10459
10460        // Corrupt the second resume file with garbage bytes.
10461        let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10462        assert!(
10463            corrupt_path.exists(),
10464            "file to corrupt must exist before overwrite"
10465        );
10466        std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10467
10468        {
10469            // Session 2: auto-restore should recover 2, fail 1, and the
10470            // orphan cleanup should delete the corrupt file.
10471            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10472                .await
10473                .unwrap();
10474
10475            // Give actor time for auto-restore + orphan cleanup.
10476            tokio::time::sleep(Duration::from_millis(200)).await;
10477
10478            let list = session.list_torrents().await.unwrap();
10479            assert_eq!(
10480                list.len(),
10481                2,
10482                "2 torrents should be restored (1 corrupt skipped)"
10483            );
10484
10485            // The good hashes should be present.
10486            assert!(
10487                list.contains(&hashes[0]),
10488                "first torrent should be restored"
10489            );
10490            assert!(
10491                list.contains(&hashes[2]),
10492                "third torrent should be restored"
10493            );
10494
10495            // The corrupt hash should NOT be present.
10496            assert!(
10497                !list.contains(&hashes[1]),
10498                "corrupted torrent should not be restored"
10499            );
10500
10501            // Also verify the corrupt file was cleaned up as orphan.
10502            assert!(
10503                !corrupt_path.exists(),
10504                "corrupt resume file should be deleted by orphan cleanup"
10505            );
10506
10507            session.shutdown().await.unwrap();
10508        }
10509    }
10510
10511    // ---- Test: remove torrent → `.resume` file deleted from disk ----
10512    //
10513    // Adds a torrent, saves resume state (creates the `.resume` file), then
10514    // removes the torrent via `session.remove_torrent()`. The removal handler
10515    // eagerly deletes the `.resume` file so it is not orphaned.
10516
10517    #[tokio::test]
10518    async fn remove_torrent_deletes_resume_file() {
10519        let tmp = tempfile::TempDir::new().unwrap();
10520
10521        let data = vec![0x42; 16384];
10522        let meta = make_test_torrent(&data, 16384);
10523        let info_hash = meta.info_hash;
10524        let storage = make_storage(&data, 16384);
10525
10526        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10527            .await
10528            .unwrap();
10529
10530        session
10531            .add_torrent_with_meta(meta.into(), Some(storage))
10532            .await
10533            .unwrap();
10534
10535        // Let the actor settle so the dirty flag is set.
10536        tokio::time::sleep(Duration::from_millis(100)).await;
10537
10538        let saved = session.save_resume_state().await.unwrap();
10539        assert!(saved > 0, "torrent should be saved to a resume file");
10540
10541        let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10542        assert!(resume_path.exists(), "resume file should exist after save");
10543
10544        // Remove the torrent — this should also delete the .resume file.
10545        session.remove_torrent(info_hash).await.unwrap();
10546        tokio::time::sleep(Duration::from_millis(50)).await;
10547
10548        let list = session.list_torrents().await.unwrap();
10549        assert!(
10550            !list.contains(&info_hash),
10551            "torrent should be gone from session after removal"
10552        );
10553
10554        assert!(
10555            !resume_path.exists(),
10556            "resume file should be deleted when torrent is removed"
10557        );
10558
10559        // Verify no .resume files remain in the torrents directory.
10560        let remaining = crate::resume_file::scan_resume_dir(tmp.path());
10561        assert!(
10562            remaining.is_empty(),
10563            "no resume files should remain after removing the only torrent"
10564        );
10565
10566        session.shutdown().await.unwrap();
10567    }
10568
10569    // ── M170: session-level storage-path tests ─────────────────────────
10570
10571    /// Test settings that use an isolated `resume_data_dir` so that
10572    /// auto-restore from a prior test run doesn't pollute `torrents`.
10573    /// Matches the precedent set by `test_settings_with_dht`.
10574    fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
10575        Settings {
10576            resume_data_dir: Some(resume_dir.to_path_buf()),
10577            ..test_settings()
10578        }
10579    }
10580
10581    #[tokio::test]
10582    async fn remove_torrent_with_files_deletes_disk_files() {
10583        // Build a real on-disk torrent via FilesystemStorage, then call
10584        // remove_torrent_with_files. The files MUST be gone from disk
10585        // after the spawn_blocking walk completes.
10586        let download_dir = tempfile::tempdir().unwrap();
10587        let resume_dir = tempfile::tempdir().unwrap();
10588        let mut settings = test_settings_isolated_resume(resume_dir.path());
10589        settings.download_dir = download_dir.path().to_path_buf();
10590        let session = SessionHandle::start(settings).await.unwrap();
10591
10592        let data = vec![0xAB_u8; 16384];
10593        let meta = make_test_torrent(&data, 16384);
10594        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10595        let storage: Arc<dyn TorrentStorage> = Arc::new(
10596            irontide_storage::FilesystemStorage::new(
10597                download_dir.path(),
10598                vec![PathBuf::from("test")],
10599                vec![data.len() as u64],
10600                lengths,
10601                None,
10602                irontide_storage::PreallocateMode::None,
10603                false,
10604            )
10605            .unwrap(),
10606        );
10607
10608        // Write actual piece data so the file is non-empty and cannot
10609        // be mistaken for a sparse leftover.
10610        storage.write_chunk(0, 0, &data).unwrap();
10611
10612        let info_hash = session
10613            .add_torrent_with_meta(meta.into(), Some(storage))
10614            .await
10615            .unwrap();
10616
10617        let file_on_disk = download_dir.path().join("test");
10618        assert!(file_on_disk.exists(), "file should exist before delete");
10619
10620        session.remove_torrent_with_files(info_hash).await.unwrap();
10621
10622        // The spawn_blocking task is fire-and-forget; poll briefly.
10623        for _ in 0..20 {
10624            if !file_on_disk.exists() {
10625                break;
10626            }
10627            tokio::time::sleep(Duration::from_millis(50)).await;
10628        }
10629        assert!(
10630            !file_on_disk.exists(),
10631            "file should have been removed from disk"
10632        );
10633        assert!(
10634            download_dir.path().exists(),
10635            "download_dir root must never be removed"
10636        );
10637
10638        session.shutdown().await.unwrap();
10639    }
10640
10641    #[tokio::test]
10642    async fn remove_torrent_with_files_tolerates_already_deleted_files() {
10643        // Partial-failure semantics: the user removed files out-of-band
10644        // before the session got the deleteFiles command. The call must
10645        // still succeed (always returns Ok).
10646        let download_dir = tempfile::tempdir().unwrap();
10647        let resume_dir = tempfile::tempdir().unwrap();
10648        let mut settings = test_settings_isolated_resume(resume_dir.path());
10649        settings.download_dir = download_dir.path().to_path_buf();
10650        let session = SessionHandle::start(settings).await.unwrap();
10651
10652        let data = vec![0xCD_u8; 16384];
10653        let meta = make_test_torrent(&data, 16384);
10654        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10655        let storage: Arc<dyn TorrentStorage> = Arc::new(
10656            irontide_storage::FilesystemStorage::new(
10657                download_dir.path(),
10658                vec![PathBuf::from("test")],
10659                vec![data.len() as u64],
10660                lengths,
10661                None,
10662                irontide_storage::PreallocateMode::None,
10663                false,
10664            )
10665            .unwrap(),
10666        );
10667        let info_hash = session
10668            .add_torrent_with_meta(meta.into(), Some(storage))
10669            .await
10670            .unwrap();
10671
10672        // Manually delete the file before calling remove_torrent_with_files.
10673        std::fs::remove_file(download_dir.path().join("test")).unwrap();
10674
10675        // Must still succeed.
10676        let result = session.remove_torrent_with_files(info_hash).await;
10677        assert!(
10678            result.is_ok(),
10679            "remove_torrent_with_files must return Ok on missing files"
10680        );
10681
10682        session.shutdown().await.unwrap();
10683    }
10684
10685    #[tokio::test]
10686    async fn remove_torrent_with_files_grace_guards_fast_re_add() {
10687        // Fast re-add during an in-flight delete must 409 until the
10688        // deletion grace window closes. Because the delete is fire-and-
10689        // forget, we simulate by calling remove_torrent_with_files and
10690        // then immediately re-adding the same info hash via
10691        // add_torrent(AddTorrentParams::bytes(...)).  The grace set is
10692        // populated synchronously inside the actor so the re-add sees it.
10693        use serde::Serialize;
10694
10695        #[derive(Serialize)]
10696        struct Info<'a> {
10697            length: u64,
10698            name: &'a str,
10699            #[serde(rename = "piece length")]
10700            piece_length: u64,
10701            #[serde(with = "serde_bytes")]
10702            pieces: &'a [u8],
10703        }
10704        #[derive(Serialize)]
10705        struct Torrent<'a> {
10706            info: Info<'a>,
10707        }
10708
10709        let download_dir = tempfile::tempdir().unwrap();
10710        let resume_dir = tempfile::tempdir().unwrap();
10711        let mut settings = test_settings_isolated_resume(resume_dir.path());
10712        settings.download_dir = download_dir.path().to_path_buf();
10713        let session = SessionHandle::start(settings).await.unwrap();
10714
10715        // Build + serialize a single-file torrent so we can re-add via
10716        // bytes after deleting.
10717        let data = vec![0xEE_u8; 16384];
10718        let meta = make_test_torrent(&data, 16384);
10719        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10720        let storage: Arc<dyn TorrentStorage> = Arc::new(
10721            irontide_storage::FilesystemStorage::new(
10722                download_dir.path(),
10723                vec![PathBuf::from("test")],
10724                vec![data.len() as u64],
10725                lengths,
10726                None,
10727                irontide_storage::PreallocateMode::None,
10728                false,
10729            )
10730            .unwrap(),
10731        );
10732        // Re-serialise the TorrentMetaV1 so we can feed the bytes back
10733        // through AddTorrentParams::bytes.
10734        let mut pieces = Vec::new();
10735        let hash = irontide_core::sha1(&data);
10736        pieces.extend_from_slice(hash.as_bytes());
10737        let bytes = irontide_bencode::to_bytes(&Torrent {
10738            info: Info {
10739                length: data.len() as u64,
10740                name: "test",
10741                piece_length: 16384,
10742                pieces: &pieces,
10743            },
10744        })
10745        .unwrap();
10746
10747        let info_hash = session
10748            .add_torrent_with_meta(meta.into(), Some(storage))
10749            .await
10750            .unwrap();
10751
10752        // Kick off the delete — the deletion_grace set is populated
10753        // inside the actor before we return.
10754        session.remove_torrent_with_files(info_hash).await.unwrap();
10755
10756        // Immediately try to re-add. The grace window may still be
10757        // open; if it is, we expect 409/CategoryBeingRemoved. If the
10758        // spawn_blocking happened to finish first, we expect success.
10759        // Either way the system must NOT panic or leak a half-deleted
10760        // torrent.
10761        let params = AddTorrentParams::bytes(bytes);
10762        let result = session.add_torrent(params).await;
10763        match result {
10764            Ok(_) => {
10765                // grace window closed before the re-add — fine.
10766            }
10767            Err(crate::Error::TorrentBeingRemoved(h)) => {
10768                assert_eq!(h, info_hash, "grace error must name the same hash");
10769            }
10770            Err(e) => panic!("unexpected error on re-add: {e}"),
10771        }
10772
10773        session.shutdown().await.unwrap();
10774    }
10775
10776    // ---- v0.173.2 T2: synchronous debug_inject_metadata ----
10777
10778    /// Synthesise a v1 info dict and its SHA-1 info hash. Returns
10779    /// `(info_bytes, info_hash)` where `info_bytes` is the bencoded info
10780    /// dict alone (not the outer .torrent wrapper) and `info_hash` is the
10781    /// SHA-1 of those bytes.
10782    ///
10783    /// The injected info hash MUST match the magnet URI's info hash, so
10784    /// this helper owns that invariant: hash exactly the bytes that will
10785    /// later be injected.
10786    #[cfg(feature = "test-util")]
10787    fn make_debug_inject_info() -> (Vec<u8>, Id20) {
10788        use serde::Serialize;
10789
10790        #[derive(Serialize)]
10791        struct Info<'a> {
10792            length: u64,
10793            name: &'a str,
10794            #[serde(rename = "piece length")]
10795            piece_length: u64,
10796            #[serde(with = "serde_bytes")]
10797            pieces: &'a [u8],
10798        }
10799
10800        let data = vec![0xAB_u8; 1024];
10801        let piece_hash = irontide_core::sha1(&data);
10802        let mut pieces = Vec::new();
10803        pieces.extend_from_slice(piece_hash.as_bytes());
10804
10805        let info = Info {
10806            length: data.len() as u64,
10807            name: "sync-inject-test",
10808            piece_length: 1024,
10809            pieces: &pieces,
10810        };
10811
10812        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
10813        let info_hash = irontide_core::sha1(&info_bytes);
10814        (info_bytes, info_hash)
10815    }
10816
10817    #[cfg(feature = "test-util")]
10818    #[tokio::test]
10819    async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
10820        use crate::session::AddTorrentParams;
10821
10822        let (info_bytes, info_hash) = make_debug_inject_info();
10823
10824        // Isolate resume dir — magnet adds persist .resume files; without
10825        // isolation, parallel tests and re-runs pollute one another. See
10826        // feedback_irontide_resume_test_isolation memory entry.
10827        let resume_dir = tempfile::tempdir().unwrap();
10828        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10829            .await
10830            .unwrap();
10831
10832        let magnet_uri = format!(
10833            "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
10834            info_hash.to_hex()
10835        );
10836        let added = session
10837            .add_torrent(AddTorrentParams::magnet(magnet_uri))
10838            .await
10839            .unwrap();
10840        assert_eq!(
10841            added, info_hash,
10842            "magnet info hash must equal synth info hash"
10843        );
10844
10845        // The synchronous contract: when `debug_inject_metadata` returns
10846        // Ok, the metadata must already be visible via `torrent_file` — no
10847        // polling, no sleep. This is what distinguishes it from the M147
10848        // fire-and-forget path.
10849        session
10850            .debug_inject_metadata(info_hash, info_bytes)
10851            .await
10852            .expect("debug_inject_metadata must succeed");
10853
10854        let meta = session
10855            .torrent_file(info_hash)
10856            .await
10857            .expect("torrent_file call")
10858            .expect("metadata must be present immediately after sync inject");
10859        assert_eq!(meta.info_hash, info_hash);
10860
10861        session.shutdown().await.unwrap();
10862    }
10863
10864    #[cfg(feature = "test-util")]
10865    #[tokio::test]
10866    async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
10867        let resume_dir = tempfile::tempdir().unwrap();
10868        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10869            .await
10870            .unwrap();
10871
10872        let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
10873        let result = session.debug_inject_metadata(bogus, vec![]).await;
10874        assert!(
10875            matches!(result, Err(crate::Error::TorrentNotFound(_))),
10876            "expected TorrentNotFound for unknown hash; got {result:?}"
10877        );
10878
10879        session.shutdown().await.unwrap();
10880    }
10881
10882    // ---- v0.173.2 T7: A11 ssl_cert metadata propagation regression ----
10883
10884    /// Synthesise a v1 info dict with optional `private` and `ssl-cert`
10885    /// fields. Unlike [`make_debug_inject_info`], this helper exists
10886    /// specifically to exercise the BEP 35 `ssl-cert` propagation path
10887    /// from the bencoded info dict into `TorrentMetaV1::info.ssl_cert`
10888    /// after synchronous metadata injection (T2's `debug_inject_metadata`).
10889    ///
10890    /// The bencode key name is `ssl-cert` (with hyphen) per BEP 35; serde
10891    /// renames it on the synthesised `Info` struct below so the emitted
10892    /// bytes match `InfoDict`'s deserialiser exactly.
10893    ///
10894    /// The caller hashes the returned bytes directly to compute the info
10895    /// hash — the returned bytes are the info dict alone (no wrapper).
10896    #[cfg(feature = "test-util")]
10897    fn build_synth_info_bytes_with_options(
10898        name: &str,
10899        length_bytes: u64,
10900        piece_length: u64,
10901        private: Option<i64>,
10902        ssl_cert: Option<Vec<u8>>,
10903    ) -> Vec<u8> {
10904        use serde::Serialize;
10905
10906        #[derive(Serialize)]
10907        struct Info {
10908            length: u64,
10909            name: String,
10910            #[serde(rename = "piece length")]
10911            piece_length: u64,
10912            pieces: serde_bytes::ByteBuf,
10913            #[serde(skip_serializing_if = "Option::is_none")]
10914            private: Option<i64>,
10915            #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
10916            ssl_cert: Option<serde_bytes::ByteBuf>,
10917        }
10918
10919        // pieces = SHA-1 of an all-zero piece, repeated. The injected data
10920        // is never verified against disk during these tests — we only care
10921        // that the bencoded info dict round-trips through the session's
10922        // metadata-resolution path with ssl_cert intact.
10923        let num_pieces = length_bytes.div_ceil(piece_length);
10924        let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
10925        let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
10926        for _ in 0..num_pieces {
10927            pieces.extend_from_slice(zero_piece_hash.as_bytes());
10928        }
10929
10930        let info = Info {
10931            length: length_bytes,
10932            name: name.to_owned(),
10933            piece_length,
10934            pieces: serde_bytes::ByteBuf::from(pieces),
10935            private,
10936            ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
10937        };
10938        irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
10939    }
10940
10941    #[cfg(feature = "test-util")]
10942    #[tokio::test]
10943    async fn ssl_cert_propagates_to_meta_after_inject() {
10944        use crate::session::AddTorrentParams;
10945
10946        let resume_dir = tempfile::tempdir().unwrap();
10947        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10948            .await
10949            .unwrap();
10950
10951        let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
10952        let info_bytes = build_synth_info_bytes_with_options(
10953            "ssl-fixture",
10954            16_384,
10955            16_384,
10956            None,
10957            Some(cert_pem.clone()),
10958        );
10959        let info_hash = irontide_core::sha1(&info_bytes);
10960
10961        let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
10962        let added = session
10963            .add_torrent(AddTorrentParams::magnet(magnet))
10964            .await
10965            .unwrap();
10966        assert_eq!(
10967            added, info_hash,
10968            "magnet info hash must equal synth info hash"
10969        );
10970
10971        session
10972            .debug_inject_metadata(info_hash, info_bytes)
10973            .await
10974            .expect("debug_inject_metadata must succeed");
10975
10976        let meta = session
10977            .torrent_file(info_hash)
10978            .await
10979            .expect("torrent_file Ok")
10980            .expect("metadata must be present immediately after sync inject");
10981        assert_eq!(
10982            meta.info.ssl_cert.as_ref(),
10983            Some(&cert_pem),
10984            "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
10985        );
10986
10987        session.shutdown().await.unwrap();
10988    }
10989
10990    #[cfg(feature = "test-util")]
10991    #[tokio::test]
10992    async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
10993        use crate::session::AddTorrentParams;
10994
10995        let resume_dir = tempfile::tempdir().unwrap();
10996        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10997            .await
10998            .unwrap();
10999
11000        let info_bytes =
11001            build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
11002        let info_hash = irontide_core::sha1(&info_bytes);
11003
11004        let magnet = format!(
11005            "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
11006            info_hash.to_hex()
11007        );
11008        let added = session
11009            .add_torrent(AddTorrentParams::magnet(magnet))
11010            .await
11011            .unwrap();
11012        assert_eq!(
11013            added, info_hash,
11014            "magnet info hash must equal synth info hash"
11015        );
11016
11017        session
11018            .debug_inject_metadata(info_hash, info_bytes)
11019            .await
11020            .expect("debug_inject_metadata must succeed");
11021
11022        let meta = session
11023            .torrent_file(info_hash)
11024            .await
11025            .expect("torrent_file Ok")
11026            .expect("metadata must be present immediately after sync inject");
11027        assert!(
11028            meta.info.ssl_cert.is_none(),
11029            "absent ssl-cert in info dict must remain None in meta; got {:?}",
11030            meta.info.ssl_cert
11031        );
11032
11033        session.shutdown().await.unwrap();
11034    }
11035
11036    // ---- P2C4: Startup init throttle tests ----
11037
11038    #[tokio::test]
11039    async fn init_throttle_queues_restored_torrents() {
11040        let tmp = tempfile::TempDir::new().unwrap();
11041        let resume_dir = tmp.path().to_path_buf();
11042
11043        // Phase 1: create a session, add 5 torrents, save resume data.
11044        {
11045            let mut settings = resume_test_settings(&resume_dir);
11046            settings.queueing_enabled = false;
11047            let session = SessionHandle::start(settings).await.unwrap();
11048            for i in 0u8..5 {
11049                let data = vec![i.wrapping_add(0xA0); 16384];
11050                let meta = make_test_torrent(&data, 16384);
11051                let storage = make_storage(&data, 16384);
11052                session
11053                    .add_torrent_with_meta(meta.into(), Some(storage))
11054                    .await
11055                    .unwrap();
11056            }
11057            tokio::time::sleep(Duration::from_millis(100)).await;
11058            let saved = session.save_resume_state().await.unwrap();
11059            assert!(saved >= 3, "should save most resume files, got {saved}");
11060            session.shutdown().await.unwrap();
11061        }
11062
11063        // Phase 2: restart with queueing_enabled and active_checking=2.
11064        {
11065            let mut settings = resume_test_settings(&resume_dir);
11066            settings.queueing_enabled = true;
11067            settings.active_checking = 2;
11068            settings.active_downloads = 2;
11069            settings.active_seeds = 2;
11070            settings.active_limit = 4;
11071            let session = SessionHandle::start(settings).await.unwrap();
11072            // M245 P1: `list_torrent_summaries` is now snapshot-backed. The
11073            // per-torrent `state` field is a SAMPLED value refreshed on the
11074            // periodic stats tick (`stats_report_interval`, 1000 ms by default),
11075            // NOT re-published on every state transition (ratified D2:
11076            // membership is eager + read-after-write, sampled fields are
11077            // eventually-consistent to one tick). The init-throttle queues a
11078            // torrent in the actor promptly, but the snapshot reflects it on the
11079            // next tick — so poll until the tick converges instead of reading
11080            // once at a fixed delay. The bounded timeout still fails the test if
11081            // the throttle never queues anything (a genuine regression).
11082            let mut queued = 0;
11083            let mut active = 0;
11084            for _ in 0..60 {
11085                let list = session.list_torrent_summaries().await.unwrap();
11086                queued = list
11087                    .iter()
11088                    .filter(|t| t.state == TorrentState::Queued)
11089                    .count();
11090                active = list
11091                    .iter()
11092                    .filter(|t| t.state != TorrentState::Queued)
11093                    .count();
11094                if queued > 0 {
11095                    break;
11096                }
11097                tokio::time::sleep(Duration::from_millis(50)).await;
11098            }
11099
11100            assert!(
11101                queued > 0,
11102                "at least one torrent should be Queued after a stats tick, but all {active} are active"
11103            );
11104            assert!(
11105                active <= 4,
11106                "active torrents ({active}) should not exceed active_limit (4)"
11107            );
11108            session.shutdown().await.unwrap();
11109        }
11110    }
11111
11112    #[tokio::test]
11113    async fn init_throttle_disabled_restores_all_immediately() {
11114        let tmp = tempfile::TempDir::new().unwrap();
11115        let resume_dir = tmp.path().to_path_buf();
11116
11117        // Phase 1: add torrents, save resume.
11118        {
11119            let settings = resume_test_settings(&resume_dir);
11120            let session = SessionHandle::start(settings).await.unwrap();
11121            for i in 0u8..3 {
11122                let data = vec![i.wrapping_add(0xC0); 16384];
11123                let meta = make_test_torrent(&data, 16384);
11124                let storage = make_storage(&data, 16384);
11125                session
11126                    .add_torrent_with_meta(meta.into(), Some(storage))
11127                    .await
11128                    .unwrap();
11129            }
11130            tokio::time::sleep(Duration::from_millis(100)).await;
11131            session.save_resume_state().await.unwrap();
11132            session.shutdown().await.unwrap();
11133        }
11134
11135        // Phase 2: restart with queueing DISABLED.
11136        {
11137            let mut settings = resume_test_settings(&resume_dir);
11138            settings.queueing_enabled = false;
11139            let session = SessionHandle::start(settings).await.unwrap();
11140            tokio::time::sleep(Duration::from_millis(200)).await;
11141
11142            let list = session.list_torrent_summaries().await.unwrap();
11143            let queued = list
11144                .iter()
11145                .filter(|t| t.state == TorrentState::Queued)
11146                .count();
11147            assert_eq!(
11148                queued, 0,
11149                "with queueing disabled, no torrents should be Queued"
11150            );
11151            session.shutdown().await.unwrap();
11152        }
11153    }
11154
11155    #[tokio::test]
11156    async fn checking_complete_triggers_immediate_eval() {
11157        use crate::alert::AlertKind;
11158
11159        let mut settings = test_settings();
11160        settings.queueing_enabled = true;
11161        settings.active_checking = 1;
11162        settings.active_downloads = 5;
11163        settings.active_seeds = 5;
11164        settings.active_limit = 10;
11165        settings.auto_manage_interval = 300;
11166        let session = SessionHandle::start(settings).await.unwrap();
11167        let mut alerts = session.subscribe();
11168
11169        // Add 3 small torrents with correct data so checking completes.
11170        let mut hashes = Vec::new();
11171        for i in 0u8..3 {
11172            let data = vec![i.wrapping_add(0xD0); 16384];
11173            let meta = make_test_torrent(&data, 16384);
11174            let storage = make_storage(&data, 16384);
11175            let h = session
11176                .add_torrent_with_meta(meta.into(), Some(storage))
11177                .await
11178                .unwrap();
11179            hashes.push(h);
11180        }
11181
11182        // Wait for at least one checking-complete state change. The trigger
11183        // should cause evaluate_queue to promote the next candidate without
11184        // waiting for the 300s auto_manage_interval.
11185        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11186        let mut saw_checking_transition = false;
11187        while tokio::time::Instant::now() < deadline {
11188            if let Ok(Ok(alert)) =
11189                tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11190                && matches!(
11191                    alert.kind,
11192                    AlertKind::StateChanged {
11193                        prev_state: TorrentState::Checking,
11194                        ..
11195                    }
11196                )
11197            {
11198                saw_checking_transition = true;
11199                break;
11200            }
11201        }
11202
11203        assert!(
11204            saw_checking_transition,
11205            "should have seen a Checking→* state transition"
11206        );
11207
11208        // After the checking-complete trigger, the evaluator should have
11209        // promoted another torrent. Give a moment for the evaluate_queue
11210        // triggered by the alert arm to run.
11211        tokio::time::sleep(Duration::from_millis(200)).await;
11212
11213        let list = session.list_torrent_summaries().await.unwrap();
11214        let active = list
11215            .iter()
11216            .filter(|t| t.state != TorrentState::Queued)
11217            .count();
11218        assert!(
11219            active >= 1,
11220            "at least one torrent should be active after checking-complete trigger"
11221        );
11222
11223        session.shutdown().await.unwrap();
11224    }
11225
11226    // ---- P2C5: Restore queue position from resume data tests ----
11227
11228    #[tokio::test]
11229    async fn resume_restores_queue_position() {
11230        let tmp = tempfile::TempDir::new().unwrap();
11231        let resume_dir = tmp.path().to_path_buf();
11232
11233        let data = vec![0xF0; 16384];
11234        let meta = make_test_torrent(&data, 16384);
11235        let info_hash = meta.info_hash;
11236
11237        // Phase 1: add torrent, set queue position, save resume.
11238        {
11239            let settings = resume_test_settings(&resume_dir);
11240            let session = SessionHandle::start(settings).await.unwrap();
11241            let storage = make_storage(&data, 16384);
11242            session
11243                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11244                .await
11245                .unwrap();
11246            session.set_queue_position(info_hash, 3).await.unwrap();
11247            tokio::time::sleep(Duration::from_millis(100)).await;
11248            session.save_resume_state().await.unwrap();
11249            session.shutdown().await.unwrap();
11250        }
11251
11252        // Phase 2: restart and verify position survived.
11253        {
11254            let settings = resume_test_settings(&resume_dir);
11255            let session = SessionHandle::start(settings).await.unwrap();
11256            tokio::time::sleep(Duration::from_millis(200)).await;
11257
11258            let pos = session.queue_position(info_hash).await.unwrap();
11259            // Renormalization reassigns 0..N-1; with a single torrent
11260            // the position is 0 regardless of saved value.
11261            assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11262            session.shutdown().await.unwrap();
11263        }
11264    }
11265
11266    #[tokio::test]
11267    async fn resume_restores_auto_managed_false() {
11268        let tmp = tempfile::TempDir::new().unwrap();
11269        let resume_dir = tmp.path().to_path_buf();
11270
11271        let data = vec![0xF1; 16384];
11272        let meta = make_test_torrent(&data, 16384);
11273        let info_hash = meta.info_hash;
11274
11275        // Phase 1: add torrent, disable auto-manage, save resume.
11276        {
11277            let settings = resume_test_settings(&resume_dir);
11278            let session = SessionHandle::start(settings).await.unwrap();
11279            let storage = make_storage(&data, 16384);
11280            session
11281                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11282                .await
11283                .unwrap();
11284            // Currently there's no direct API to set auto_managed on
11285            // SessionHandle — it's internal to TorrentEntry. Verify that
11286            // the resume round-trip preserves the default (true → 1).
11287            tokio::time::sleep(Duration::from_millis(100)).await;
11288            session.save_resume_state().await.unwrap();
11289            session.shutdown().await.unwrap();
11290        }
11291
11292        // Manually patch the resume file to set auto_managed=0.
11293        {
11294            let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11295            if path.exists() {
11296                let bytes = std::fs::read(&path).unwrap();
11297                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11298                rd.auto_managed = 0;
11299                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11300                std::fs::write(&path, patched).unwrap();
11301            }
11302        }
11303
11304        // Phase 2: restart and verify auto_managed was restored as false.
11305        {
11306            let settings = resume_test_settings(&resume_dir);
11307            let session = SessionHandle::start(settings).await.unwrap();
11308            tokio::time::sleep(Duration::from_millis(200)).await;
11309
11310            let stats = session.torrent_stats(info_hash).await.unwrap();
11311            assert!(
11312                !stats.auto_managed,
11313                "auto_managed should be false after restore"
11314            );
11315            session.shutdown().await.unwrap();
11316        }
11317    }
11318
11319    #[tokio::test]
11320    async fn resume_renormalizes_duplicate_positions() {
11321        let tmp = tempfile::TempDir::new().unwrap();
11322        let resume_dir = tmp.path().to_path_buf();
11323
11324        // Phase 1: add 3 torrents, save resume.
11325        let mut hashes = Vec::new();
11326        {
11327            let settings = resume_test_settings(&resume_dir);
11328            let session = SessionHandle::start(settings).await.unwrap();
11329            for i in 0u8..3 {
11330                let data = vec![i.wrapping_add(0xE0); 16384];
11331                let meta = make_test_torrent(&data, 16384);
11332                let storage = make_storage(&data, 16384);
11333                let h = session
11334                    .add_torrent_with_meta(meta.into(), Some(storage))
11335                    .await
11336                    .unwrap();
11337                hashes.push(h);
11338            }
11339            tokio::time::sleep(Duration::from_millis(100)).await;
11340            session.save_resume_state().await.unwrap();
11341            session.shutdown().await.unwrap();
11342        }
11343
11344        // Manually patch ALL resume files to have queue_position=0.
11345        for hash in &hashes {
11346            let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11347            if path.exists() {
11348                let bytes = std::fs::read(&path).unwrap();
11349                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11350                rd.queue_position = 0;
11351                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11352                std::fs::write(&path, patched).unwrap();
11353            }
11354        }
11355
11356        // Phase 2: restart and verify positions are contiguous 0,1,2.
11357        {
11358            let settings = resume_test_settings(&resume_dir);
11359            let session = SessionHandle::start(settings).await.unwrap();
11360            tokio::time::sleep(Duration::from_millis(200)).await;
11361
11362            let mut positions = Vec::new();
11363            for hash in &hashes {
11364                if let Ok(pos) = session.queue_position(*hash).await {
11365                    positions.push(pos);
11366                }
11367            }
11368            positions.sort_unstable();
11369            let expected: Vec<i32> = (0..positions.len() as i32).collect();
11370            assert_eq!(
11371                positions, expected,
11372                "positions should be contiguous 0..N-1 after renormalization"
11373            );
11374            session.shutdown().await.unwrap();
11375        }
11376    }
11377
11378    // ---- P2C6: EWMA rate smoothing tests ----
11379
11380    #[test]
11381    fn ewma_smooths_transient_drop() {
11382        let alpha = 0.3_f64;
11383        let prev = 100_000.0_f64;
11384        let sample = 0.0_f64;
11385        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11386        assert!(
11387            (smoothed - 70_000.0).abs() < 1.0,
11388            "smoothed rate should be ~70000, got {smoothed}"
11389        );
11390    }
11391
11392    #[test]
11393    fn ewma_alpha_one_equals_raw() {
11394        let alpha = 1.0_f64;
11395        let prev = 100_000.0_f64;
11396        let sample = 42_000.0_f64;
11397        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11398        assert!(
11399            (smoothed - sample).abs() < 0.001,
11400            "alpha=1.0 should produce raw rate, got {smoothed}"
11401        );
11402    }
11403
11404    // ---- P2C7: Configurable seed anti-flap duration tests ----
11405
11406    #[test]
11407    fn seed_anti_flap_uses_longer_duration() {
11408        let seed_queue_min_active_secs = 1800_u64;
11409        let auto_manage_startup = 60_u64;
11410        let started_5_min_ago = std::time::Duration::from_mins(5);
11411        let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11412
11413        // Seeding torrent started 5 min ago: still recently_started
11414        // with seed_queue_min_active_secs=1800.
11415        assert!(
11416            started_5_min_ago < seed_duration,
11417            "5 min < 30 min, seeding torrent should be recently_started"
11418        );
11419
11420        // Downloading torrent started 5 min ago: NOT recently_started
11421        // with auto_manage_startup=60.
11422        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11423        assert!(
11424            started_5_min_ago > dl_duration,
11425            "5 min > 60s, downloading torrent should NOT be recently_started"
11426        );
11427    }
11428
11429    #[test]
11430    fn download_anti_flap_uses_startup_duration() {
11431        let auto_manage_startup = 60_u64;
11432        let started_5_min_ago = std::time::Duration::from_mins(5);
11433        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11434        assert!(
11435            started_5_min_ago > dl_duration,
11436            "downloading torrent started 5 min ago should NOT be recently_started"
11437        );
11438    }
11439
11440    // ── M214: classify round-trip for Connection + Speed fields ──────
11441
11442    #[test]
11443    fn classify_restart_required_upnp_change() {
11444        let old = Settings::default();
11445        let mut new = old.clone();
11446        new.enable_upnp = !old.enable_upnp;
11447        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11448        assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11449    }
11450
11451    #[test]
11452    fn classify_restart_required_natpmp_change() {
11453        let old = Settings::default();
11454        let mut new = old.clone();
11455        new.enable_natpmp = !old.enable_natpmp;
11456        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11457        assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11458    }
11459
11460    #[test]
11461    fn classify_immediate_max_connec_global_change() {
11462        let old = Settings::default();
11463        let mut new = old.clone();
11464        new.max_connections_global = if old.max_connections_global == 500 {
11465            501
11466        } else {
11467            500
11468        };
11469        assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11470        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11471    }
11472
11473    #[test]
11474    fn classify_immediate_max_uploads_per_torrent_change() {
11475        // M224: per-torrent upload slot cap is classified immediate; the
11476        // choker reads the new cap at its next unchoke tick. Mirrors the
11477        // max_connec_global pattern from M214.
11478        let old = Settings::default();
11479        let mut new = old.clone();
11480        new.max_uploads_per_torrent = 4;
11481        assert_eq!(
11482            classify_immediate(&old, &new),
11483            vec!["max_uploads_per_torrent"]
11484        );
11485        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11486    }
11487
11488    #[test]
11489    fn classify_restart_required_proxy_type_change() {
11490        let old = Settings::default();
11491        let mut new = old.clone();
11492        new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11493        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11494        assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11495    }
11496
11497    #[test]
11498    fn classify_restart_required_proxy_credentials_change() {
11499        let old = Settings::default();
11500        let mut new = old.clone();
11501        new.proxy.username = Some("alice".into());
11502        new.proxy.password = Some("secret".into());
11503        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11504        let restart = classify_restart_required(&old, &new);
11505        // Both fields must surface; order is implementation-defined but the
11506        // set must equal {proxy_username, proxy_password}.
11507        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11508        assert_eq!(
11509            set,
11510            ["proxy_username", "proxy_password"]
11511                .into_iter()
11512                .collect::<std::collections::HashSet<_>>()
11513        );
11514    }
11515
11516    #[test]
11517    fn classify_combined_immediate_and_restart() {
11518        // Multi-field diff: max_connec_global + max_uploads_per_torrent
11519        // (both immediate) + upnp + proxy_type (both restart) should populate
11520        // both lists. M224 extends the immediate side.
11521        let old = Settings::default();
11522        let mut new = old.clone();
11523        new.max_connections_global = old.max_connections_global + 1;
11524        new.max_uploads_per_torrent = 4;
11525        new.enable_upnp = !old.enable_upnp;
11526        new.proxy.proxy_type = crate::proxy::ProxyType::Http;
11527
11528        let immediate = classify_immediate(&old, &new);
11529        let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
11530        assert_eq!(
11531            imm_set,
11532            ["max_connec_global", "max_uploads_per_torrent"]
11533                .into_iter()
11534                .collect::<std::collections::HashSet<_>>()
11535        );
11536        let restart = classify_restart_required(&old, &new);
11537        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11538        assert_eq!(
11539            set,
11540            ["upnp", "proxy_type"]
11541                .into_iter()
11542                .collect::<std::collections::HashSet<_>>()
11543        );
11544    }
11545
11546    // ── M215: BitTorrent + Advanced classify-list verification ──────
11547
11548    #[test]
11549    fn classify_immediate_seed_time_limit_change() {
11550        let old = Settings::default();
11551        let mut new = old.clone();
11552        new.seed_time_limit_secs = Some(3600);
11553        assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
11554        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11555    }
11556
11557    #[test]
11558    fn classify_immediate_inactive_seed_time_limit_change() {
11559        let old = Settings::default();
11560        let mut new = old.clone();
11561        new.inactive_seed_time_limit_secs = Some(1800);
11562        assert_eq!(
11563            classify_immediate(&old, &new),
11564            vec!["max_inactive_seeding_time"]
11565        );
11566        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11567    }
11568
11569    // M225: `classify_restart_required_hashing_threads_change` and
11570    // `classify_restart_required_save_resume_interval_change` (formerly here)
11571    // were DELETED in M225 — those fields are now `classify_immediate` per
11572    // OV F2c / F4. Replacement immediate-dispatch coverage follows.
11573
11574    #[test]
11575    fn classify_immediate_save_resume_interval_change() {
11576        // M225 Step 1: save_resume_interval graduated from restart_required
11577        // to immediate. SessionActor rebuilds the resume_save_interval timer
11578        // via Arc<Notify> on dispatch — no daemon restart needed.
11579        let old = Settings::default();
11580        let mut new = old.clone();
11581        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
11582        assert_eq!(classify_immediate(&old, &new), vec!["save_resume_interval"]);
11583        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11584    }
11585
11586    #[test]
11587    fn classify_immediate_hashing_threads_change() {
11588        // M225 Step 2: hashing_threads graduated from restart_required to
11589        // immediate. The per-torrent piece-verify batch reads
11590        // self.config.hashing_threads at the start of each batch, so a value
11591        // change applies on the NEXT batch via the existing
11592        // TorrentCommand::UpdateSettings(SettingsDelta) fan-out path.
11593        let old = Settings::default();
11594        let mut new = old.clone();
11595        new.hashing_threads = old.hashing_threads.saturating_add(2);
11596        assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
11597        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11598    }
11599
11600    #[test]
11601    fn classify_immediate_ip_filter_enabled_change() {
11602        // M225 Step 3: ip_filter_enabled graduated to immediate. apply_settings
11603        // writes through Arc<RwLock<IpFilter>> with self.ip_filter.write().enabled
11604        // = enabled; future is_blocked calls observe the new value on the next
11605        // RwLock read.
11606        let old = Settings::default();
11607        let mut new = old.clone();
11608        new.ip_filter_enabled = !old.ip_filter_enabled;
11609        assert_eq!(classify_immediate(&old, &new), vec!["ip_filter_enabled"]);
11610        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11611    }
11612
11613    #[test]
11614    fn settings_delta_from_diff_includes_save_resume_interval() {
11615        // M225 Step 1: SettingsDelta carries save_resume_interval_secs so
11616        // apply_settings can dispatch the Notify on observed change.
11617        use crate::types::SettingsDelta;
11618        let old = Settings::default();
11619        let mut new = old.clone();
11620        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
11621        let d = SettingsDelta::from_diff(&old, &new);
11622        assert_eq!(
11623            d.save_resume_interval_secs,
11624            Some(new.save_resume_interval_secs)
11625        );
11626        assert!(d.hashing_threads.is_none());
11627        assert!(d.ip_filter_enabled.is_none());
11628        assert!(!d.is_empty());
11629    }
11630
11631    #[test]
11632    fn settings_delta_from_diff_includes_hashing_threads() {
11633        // M225 Step 2: SettingsDelta carries hashing_threads so the existing
11634        // TorrentCommand::UpdateSettings fan-out propagates per-torrent.
11635        use crate::types::SettingsDelta;
11636        let old = Settings::default();
11637        let mut new = old.clone();
11638        new.hashing_threads = old.hashing_threads.saturating_add(1);
11639        let d = SettingsDelta::from_diff(&old, &new);
11640        assert_eq!(d.hashing_threads, Some(new.hashing_threads));
11641        assert!(d.save_resume_interval_secs.is_none());
11642        assert!(d.ip_filter_enabled.is_none());
11643        assert!(!d.is_empty());
11644    }
11645
11646    #[test]
11647    fn settings_delta_from_diff_includes_ip_filter_enabled() {
11648        // M225 Step 3: SettingsDelta carries ip_filter_enabled. apply_settings
11649        // applies it through the outer Arc<RwLock<IpFilter>> write-lock.
11650        use crate::types::SettingsDelta;
11651        let old = Settings::default();
11652        let mut new = old.clone();
11653        new.ip_filter_enabled = !old.ip_filter_enabled;
11654        let d = SettingsDelta::from_diff(&old, &new);
11655        assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
11656        assert!(d.save_resume_interval_secs.is_none());
11657        assert!(d.hashing_threads.is_none());
11658        assert!(!d.is_empty());
11659    }
11660
11661    #[test]
11662    fn settings_delta_is_empty_honours_m225_fields() {
11663        // M225: is_empty must return false when any M225 field is set so the
11664        // fan-out path runs.
11665        use crate::types::SettingsDelta;
11666        let mut d = SettingsDelta::default();
11667        assert!(d.is_empty());
11668        d.save_resume_interval_secs = Some(120);
11669        assert!(!d.is_empty());
11670        d = SettingsDelta::default();
11671        d.hashing_threads = Some(8);
11672        assert!(!d.is_empty());
11673        d = SettingsDelta::default();
11674        d.ip_filter_enabled = Some(false);
11675        assert!(!d.is_empty());
11676    }
11677
11678    // ── M226: SettingsDelta + classify_immediate coverage ──────────────────
11679
11680    /// Helper for compact M226 delta+classify tests: toggle one field, verify
11681    /// the delta picks it up AND [`classify_immediate`] yields the expected
11682    /// alias AND [`classify_restart_required`] stays empty.
11683    fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
11684    where
11685        F: FnOnce(&mut Settings),
11686    {
11687        use crate::types::SettingsDelta;
11688        let old = Settings::default();
11689        let mut new = old.clone();
11690        mutate(&mut new);
11691        let d = SettingsDelta::from_diff(&old, &new);
11692        assert!(
11693            !d.is_empty(),
11694            "{alias}: delta must not be empty after toggle"
11695        );
11696        let imm = classify_immediate(&old, &new);
11697        assert!(
11698            imm.contains(&alias),
11699            "{alias}: classify_immediate must contain alias, got {imm:?}"
11700        );
11701        let rr = classify_restart_required(&old, &new);
11702        assert!(
11703            !rr.contains(&alias),
11704            "{alias}: must NOT appear in classify_restart_required"
11705        );
11706    }
11707
11708    #[test]
11709    fn m226_notify_on_complete_immediate() {
11710        m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
11711    }
11712
11713    #[test]
11714    fn m226_notify_on_error_immediate() {
11715        m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
11716    }
11717
11718    #[test]
11719    fn m226_on_complete_program_immediate() {
11720        m226_delta_and_classify_check(
11721            |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
11722            "on_complete_program",
11723        );
11724    }
11725
11726    #[test]
11727    fn m226_use_incomplete_dir_immediate() {
11728        m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
11729    }
11730
11731    #[test]
11732    fn m226_incomplete_dir_immediate() {
11733        m226_delta_and_classify_check(
11734            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
11735            "incomplete_dir",
11736        );
11737    }
11738
11739    #[test]
11740    fn m226_default_skip_hash_check_immediate() {
11741        m226_delta_and_classify_check(
11742            |s| s.default_skip_hash_check = true,
11743            "default_skip_hash_check",
11744        );
11745    }
11746
11747    #[test]
11748    fn m226_incomplete_extension_enabled_immediate() {
11749        // Default is TRUE so we toggle false to observe diff.
11750        m226_delta_and_classify_check(
11751            |s| s.incomplete_extension_enabled = false,
11752            "incomplete_extension_enabled",
11753        );
11754    }
11755
11756    #[test]
11757    fn m226_watched_folder_immediate() {
11758        m226_delta_and_classify_check(
11759            |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
11760            "watched_folder",
11761        );
11762    }
11763
11764    #[test]
11765    fn m226_delete_torrent_after_add_immediate() {
11766        m226_delta_and_classify_check(
11767            |s| s.delete_torrent_after_add = true,
11768            "delete_torrent_after_add",
11769        );
11770    }
11771
11772    #[test]
11773    fn m226_move_completed_enabled_immediate() {
11774        m226_delta_and_classify_check(
11775            |s| s.move_completed_enabled = true,
11776            "move_completed_enabled",
11777        );
11778    }
11779
11780    #[test]
11781    fn m226_move_completed_to_immediate() {
11782        m226_delta_and_classify_check(
11783            |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
11784            "move_completed_to",
11785        );
11786    }
11787
11788    #[test]
11789    fn m226_ip_filter_auto_refresh_immediate() {
11790        m226_delta_and_classify_check(
11791            |s| s.ip_filter_auto_refresh = true,
11792            "ip_filter_auto_refresh",
11793        );
11794    }
11795
11796    #[test]
11797    fn m226_web_ui_https_enabled_immediate() {
11798        m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
11799    }
11800
11801    #[test]
11802    fn m226_network_interface_immediate() {
11803        m226_delta_and_classify_check(
11804            |s| s.network_interface = Some("eth0".into()),
11805            "network_interface",
11806        );
11807    }
11808
11809    #[test]
11810    fn m226_default_add_paused_immediate() {
11811        m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
11812    }
11813
11814    #[test]
11815    fn m226_delta_clears_optional_path_incomplete_dir() {
11816        // F4 — outer Some + inner None means "clear to None". Without nested
11817        // Option this case is indistinguishable from "no change".
11818        use crate::types::SettingsDelta;
11819        let old = Settings {
11820            incomplete_dir: Some(std::path::PathBuf::from("/foo")),
11821            ..Settings::default()
11822        };
11823        let new = Settings {
11824            incomplete_dir: None,
11825            ..old.clone()
11826        };
11827        let d = SettingsDelta::from_diff(&old, &new);
11828        assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
11829        assert!(!d.is_empty());
11830    }
11831
11832    #[test]
11833    fn m226_delta_clears_optional_path_watched_folder() {
11834        // F4 — same pattern for watched_folder.
11835        use crate::types::SettingsDelta;
11836        let old = Settings {
11837            watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
11838            ..Settings::default()
11839        };
11840        let new = Settings {
11841            watched_folder: None,
11842            ..old.clone()
11843        };
11844        let d = SettingsDelta::from_diff(&old, &new);
11845        assert_eq!(d.watched_folder, Some(None));
11846        assert!(!d.is_empty());
11847    }
11848
11849    #[test]
11850    fn m226_delta_is_empty_honours_new_fields() {
11851        // is_empty must return false when ANY M226 field is set.
11852        use crate::types::SettingsDelta;
11853        let mut d = SettingsDelta::default();
11854        assert!(d.is_empty());
11855        d.notify_on_complete = Some(true);
11856        assert!(!d.is_empty());
11857        d = SettingsDelta::default();
11858        d.watched_folder = Some(None); // clear-to-None still counts
11859        assert!(!d.is_empty());
11860        d = SettingsDelta::default();
11861        d.default_add_paused = Some(true);
11862        assert!(!d.is_empty());
11863    }
11864
11865    #[test]
11866    fn m226_no_fields_appear_in_restart_required() {
11867        // Negative coverage: toggling each of the 15 M226 fields one at a
11868        // time must NOT produce any classify_restart_required entries.
11869        type Mutation = fn(&mut Settings);
11870        let mutations: [Mutation; 15] = [
11871            |s| s.notify_on_complete = true,
11872            |s| s.notify_on_error = true,
11873            |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
11874            |s| s.use_incomplete_dir = true,
11875            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
11876            |s| s.default_skip_hash_check = true,
11877            |s| s.incomplete_extension_enabled = false,
11878            |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
11879            |s| s.delete_torrent_after_add = true,
11880            |s| s.move_completed_enabled = true,
11881            |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
11882            |s| s.ip_filter_auto_refresh = true,
11883            |s| s.web_ui_https_enabled = true,
11884            |s| s.network_interface = Some("eth0".into()),
11885            |s| s.default_add_paused = true,
11886        ];
11887        let old = Settings::default();
11888        for (idx, m) in mutations.iter().enumerate() {
11889            let mut new = old.clone();
11890            m(&mut new);
11891            let rr = classify_restart_required(&old, &new);
11892            assert!(
11893                rr.is_empty(),
11894                "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
11895            );
11896        }
11897    }
11898
11899    #[test]
11900    fn classify_immediate_seed_time_and_inactive_combined() {
11901        // Both seed-time limits flipped in one delta — both must surface
11902        // in `immediate`, none in `restart_required`.
11903        let old = Settings::default();
11904        let mut new = old.clone();
11905        new.seed_time_limit_secs = Some(7200);
11906        new.inactive_seed_time_limit_secs = Some(900);
11907        let imm = classify_immediate(&old, &new);
11908        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11909        assert_eq!(
11910            set,
11911            ["max_seeding_time", "max_inactive_seeding_time"]
11912                .into_iter()
11913                .collect::<std::collections::HashSet<_>>()
11914        );
11915        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11916    }
11917
11918    #[test]
11919    fn classify_combined_seed_time_and_hashing_both_immediate() {
11920        // M225: hashing_threads graduated from restart_required to immediate
11921        // (D-eng-2 revised). With seed_time_limit also immediate, both fields
11922        // must surface in immediate, none in restart_required.
11923        let old = Settings::default();
11924        let mut new = old.clone();
11925        new.seed_time_limit_secs = Some(1200);
11926        new.hashing_threads = old.hashing_threads.saturating_add(2);
11927        let imm = classify_immediate(&old, &new);
11928        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11929        assert_eq!(
11930            set,
11931            ["max_seeding_time", "hashing_threads"]
11932                .into_iter()
11933                .collect::<std::collections::HashSet<_>>()
11934        );
11935        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11936    }
11937
11938    #[test]
11939    fn classify_combined_hashing_and_save_resume_both_immediate() {
11940        // M225: hashing_threads and save_resume_interval both graduated from
11941        // restart_required to immediate. Mixed change must land both in
11942        // immediate, none in restart_required.
11943        let old = Settings::default();
11944        let mut new = old.clone();
11945        new.hashing_threads = old.hashing_threads.saturating_add(3);
11946        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
11947        let imm = classify_immediate(&old, &new);
11948        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11949        assert_eq!(
11950            set,
11951            ["hashing_threads", "save_resume_interval"]
11952                .into_iter()
11953                .collect::<std::collections::HashSet<_>>()
11954        );
11955        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11956    }
11957
11958    // ── M226: AddTorrentParams.paused: Option<bool> resolution ──────────
11959    //
11960    // The constructor default `paused: None` means "honour the engine's
11961    // `default_add_paused`"; an explicit `.paused(v)` call wraps `Some(v)`
11962    // and wins over the engine setting. Tests exercise all 4 combinations
11963    // (3 here + 1 implicit default already covered by existing
11964    // `pause_resume_via_session` and `add_torrent_with_meta` tests).
11965
11966    /// Re-bencode a single-piece v1 torrent so the bytes branch of
11967    /// `add_torrent` can ingest it. Helper avoids dragging
11968    /// `add_torrent_with_meta` into the M226 tests (that bypasses
11969    /// `AddTorrentParams` entirely).
11970    fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
11971        use serde::Serialize;
11972
11973        #[derive(Serialize)]
11974        struct Info<'a> {
11975            length: u64,
11976            name: &'a str,
11977            #[serde(rename = "piece length")]
11978            piece_length: u64,
11979            #[serde(with = "serde_bytes")]
11980            pieces: &'a [u8],
11981        }
11982        #[derive(Serialize)]
11983        struct Torrent<'a> {
11984            info: Info<'a>,
11985        }
11986
11987        let mut pieces = Vec::new();
11988        let mut offset = 0;
11989        while offset < data.len() {
11990            let end = (offset + piece_length as usize).min(data.len());
11991            let hash = irontide_core::sha1(&data[offset..end]);
11992            pieces.extend_from_slice(hash.as_bytes());
11993            offset = end;
11994        }
11995
11996        irontide_bencode::to_bytes(&Torrent {
11997            info: Info {
11998                length: data.len() as u64,
11999                name: "m226-test",
12000                piece_length,
12001                pieces: &pieces,
12002            },
12003        })
12004        .unwrap()
12005    }
12006
12007    /// M226 D1 acceptance: `default_add_paused = true` + caller passes no
12008    /// explicit `.paused(...)` → torrent must land paused.
12009    #[tokio::test]
12010    async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
12011        let mut settings = test_settings();
12012        settings.default_add_paused = true;
12013        let session = SessionHandle::start(settings).await.unwrap();
12014
12015        let data = vec![0xAB; 16384];
12016        let bytes = m226_make_torrent_bytes(&data, 16384);
12017        let info_hash = session
12018            .add_torrent(AddTorrentParams::bytes(bytes))
12019            .await
12020            .unwrap();
12021
12022        // Pause is dispatched via `tokio::spawn(handle.pause())`; give the
12023        // dispatched task a tick to land before we read state.
12024        tokio::time::sleep(Duration::from_millis(100)).await;
12025        let stats = session.torrent_stats(info_hash).await.unwrap();
12026        assert_eq!(
12027            stats.state,
12028            TorrentState::Paused,
12029            "engine default_add_paused=true must pause the torrent when caller \
12030             passes AddTorrentParams::bytes() without an explicit .paused(...)"
12031        );
12032
12033        session.shutdown().await.unwrap();
12034    }
12035
12036    /// M226 D1 acceptance: explicit `.paused(false)` must beat
12037    /// `default_add_paused = true` — the engine setting is the fallback,
12038    /// the per-call override is authoritative.
12039    #[tokio::test]
12040    async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
12041        let mut settings = test_settings();
12042        settings.default_add_paused = true;
12043        let session = SessionHandle::start(settings).await.unwrap();
12044
12045        let data = vec![0xCD; 16384];
12046        let bytes = m226_make_torrent_bytes(&data, 16384);
12047        let info_hash = session
12048            .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
12049            .await
12050            .unwrap();
12051
12052        // Negative assertion: nothing should run a paused dispatch path —
12053        // a brief sleep guards against a phantom spawned pause.
12054        tokio::time::sleep(Duration::from_millis(100)).await;
12055        let stats = session.torrent_stats(info_hash).await.unwrap();
12056        assert_ne!(
12057            stats.state,
12058            TorrentState::Paused,
12059            "explicit .paused(false) must override default_add_paused=true; \
12060             got state={:?}",
12061            stats.state
12062        );
12063
12064        session.shutdown().await.unwrap();
12065    }
12066
12067    /// M226 D1 acceptance: explicit `.paused(true)` must beat
12068    /// `default_add_paused = false` — mirror image of the previous test
12069    /// to cover the other direction of "explicit wins over default".
12070    #[tokio::test]
12071    async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
12072        let mut settings = test_settings();
12073        settings.default_add_paused = false;
12074        let session = SessionHandle::start(settings).await.unwrap();
12075
12076        let data = vec![0xEF; 16384];
12077        let bytes = m226_make_torrent_bytes(&data, 16384);
12078        let info_hash = session
12079            .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
12080            .await
12081            .unwrap();
12082
12083        tokio::time::sleep(Duration::from_millis(100)).await;
12084        let stats = session.torrent_stats(info_hash).await.unwrap();
12085        assert_eq!(
12086            stats.state,
12087            TorrentState::Paused,
12088            "explicit .paused(true) must pause even when \
12089             default_add_paused=false"
12090        );
12091
12092        session.shutdown().await.unwrap();
12093    }
12094}