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    /// M252/ER5: Content layout override for this add. `None` (the
113    /// constructor default) derives from [`Settings::create_subfolder`]
114    /// (`true` → `Original`, `false` → `NoSubfolder`); `Some(layout)` is an
115    /// explicit per-add override and wins over the setting.
116    pub content_layout: Option<irontide_core::ContentLayout>,
117    /// M254: preallocation override for this add. `None` uses
118    /// `Settings.preallocate_mode`.
119    pub preallocate_mode: Option<irontide_storage::PreallocateMode>,
120    /// M254: whether the session queue auto-manages this torrent.
121    /// `None` (default) = `true` — today's unconditional behaviour.
122    pub auto_managed: Option<bool>,
123    /// M254: enable sequential downloading from the first dispatch.
124    pub sequential_download: Option<bool>,
125    /// M254: enable first/last-pieces-first ordering (M253) from add.
126    pub prioritize_first_last_pieces: Option<bool>,
127    /// M254: inline per-file priorities, honoured at the FIRST
128    /// wanted-pieces build. Empty = all `Normal`. Shorter pads, longer
129    /// truncates (multi-file counts unknown until metadata for magnets).
130    pub file_priorities: Vec<irontide_core::FilePriority>,
131}
132
133impl AddTorrentParams {
134    /// Build a magnet-source add with default knobs.
135    #[must_use]
136    pub fn magnet(uri: impl Into<String>) -> Self {
137        Self {
138            source: AddSource::Magnet(uri.into()),
139            category: None,
140            tags: Vec::new(),
141            download_dir: None,
142            paused: None,
143            skip_checking: false,
144            content_layout: None,
145            preallocate_mode: None,
146            auto_managed: None,
147            sequential_download: None,
148            prioritize_first_last_pieces: None,
149            file_priorities: Vec::new(),
150        }
151    }
152
153    /// Build a bytes-source add with default knobs.
154    #[must_use]
155    pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
156        Self {
157            source: AddSource::Bytes(data.into()),
158            category: None,
159            tags: Vec::new(),
160            download_dir: None,
161            paused: None,
162            skip_checking: false,
163            content_layout: None,
164            preallocate_mode: None,
165            auto_managed: None,
166            sequential_download: None,
167            prioritize_first_last_pieces: None,
168            file_priorities: Vec::new(),
169        }
170    }
171
172    /// Assign a category label to the torrent. The session resolves the
173    /// name against its registry at add-time; unknown names error out.
174    #[must_use]
175    pub fn with_category(mut self, name: impl Into<String>) -> Self {
176        self.category = Some(name.into());
177        self
178    }
179
180    /// M171: Attach tags at add time. Tags are baked into the torrent's
181    /// config before `TorrentActor::new`, so the first `stats()` call
182    /// returns them — no post-add spawn race.
183    #[must_use]
184    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
185        self.tags = tags;
186        self
187    }
188
189    /// M252/ER5: Override the content layout for this add. `Original`
190    /// keeps the torrent's own structure, `Subfolder` always wraps in a
191    /// root folder named after the torrent, `NoSubfolder` strips the root
192    /// folder. Unset, the layout derives from [`Settings::create_subfolder`].
193    #[must_use]
194    pub fn with_content_layout(mut self, layout: irontide_core::ContentLayout) -> Self {
195        self.content_layout = Some(layout);
196        self
197    }
198
199    /// Override the download directory for this torrent.
200    #[must_use]
201    pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
202        self.download_dir = Some(dir.into());
203        self
204    }
205
206    /// M254: override the preallocation mode for this torrent.
207    #[must_use]
208    pub fn with_preallocate_mode(mut self, mode: irontide_storage::PreallocateMode) -> Self {
209        self.preallocate_mode = Some(mode);
210        self
211    }
212
213    /// M254: opt this torrent out of (or explicitly into) session queue
214    /// auto-management. Unset = auto-managed, today's behaviour.
215    #[must_use]
216    pub fn auto_managed(mut self, enabled: bool) -> Self {
217        self.auto_managed = Some(enabled);
218        self
219    }
220
221    /// M254: enable sequential (in-order) downloading from the first
222    /// dispatch — no post-add toggle round-trip.
223    #[must_use]
224    pub fn sequential_download(mut self, enabled: bool) -> Self {
225        self.sequential_download = Some(enabled);
226        self
227    }
228
229    /// M254: enable first/last-pieces-first ordering (M253) from add.
230    #[must_use]
231    pub fn prioritize_first_last_pieces(mut self, enabled: bool) -> Self {
232        self.prioritize_first_last_pieces = Some(enabled);
233        self
234    }
235
236    /// M254: set per-file priorities inline at add. Empty = all Normal.
237    #[must_use]
238    pub fn with_file_priorities(mut self, priorities: Vec<irontide_core::FilePriority>) -> Self {
239        self.file_priorities = priorities;
240        self
241    }
242
243    /// Toggle the paused-at-add flag. Wraps the explicit choice in `Some(_)`
244    /// so the actor can distinguish "caller explicitly set this" from
245    /// "caller did not touch it" (the latter falls back to
246    /// [`Settings::default_add_paused`]).
247    #[must_use]
248    pub fn paused(mut self, paused: bool) -> Self {
249        self.paused = Some(paused);
250        self
251    }
252
253    /// Toggle the skip-initial-check flag (M171+).
254    #[must_use]
255    pub fn skip_checking(mut self, skip: bool) -> Self {
256        self.skip_checking = skip;
257        self
258    }
259}
260
261/// Entry for a torrent managed by the session.
262///
263/// v0.173.1: the `meta: Option<TorrentMetaV1>` field was deleted. It was a
264/// stale cache that was silently `None` forever for magnet-added torrents —
265/// see the Class A archaeology in
266/// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`. Four reader
267/// sites (`handle_torrent_info`, `handle_remove_torrent_with_files`, `is_private`,
268/// `handle_ssl_incoming`) now query the `TorrentActor` via `handle.get_meta()`,
269/// which is the single source of truth for torrent metadata.
270struct TorrentEntry {
271    handle: TorrentHandle,
272    /// Queue position (-1 = not queued / not auto-managed).
273    queue_position: i32,
274    /// Whether the queue system controls this torrent.
275    auto_managed: bool,
276    /// When the torrent was last started/resumed (for startup grace period).
277    started_at: Option<tokio::time::Instant>,
278    /// EWMA-smoothed download rate for queue inactive classification.
279    smoothed_download_rate: f64,
280    /// EWMA-smoothed upload rate for queue inactive classification.
281    smoothed_upload_rate: f64,
282}
283
284/// M223 — off-actor add-torrent result bundle.
285///
286/// `handle_add_torrent` (and the M170 path) split into two phases:
287/// 1. **Prepare** — disk register, actor spawn. Runs in a `tokio::spawn`
288///    task off the `SessionActor` recv loop, so concurrent adds do not
289///    serialise the actor's per-command queue.
290/// 2. **Commit** — mutating fixup on the actor: insert into
291///    `self.torrents`, info-hash registry, queue position, alert, LSD
292///    announce. Runs on the actor in response to a `CommitAddTorrent`
293///    feedback command.
294///
295/// This bundle is the success-path payload of the prepare phase; the
296/// commit phase consumes it and produces the caller-visible `Id20`.
297/// `is_private` is precomputed from `meta.info.private` so the commit
298/// arm needs no async query to honour BEP 27 (LSD must skip private
299/// torrents).
300struct PreparedAddTorrent {
301    handle: TorrentHandle,
302    info_hash: Id20,
303    is_private: bool,
304    /// M170 post-add hooks (category label + paused-on-add). `None` for
305    /// the legacy `AddTorrent` path; `Some` only for the
306    /// `AddTorrentM170` path which carries `AddTorrentParams`.
307    m170_post: Option<M170PostAdd>,
308    /// M254 (D2): session-entry auto-managed override, threaded to the
309    /// commit arm's entry literal. `None` = `true` (legacy behaviour).
310    auto_managed: Option<bool>,
311}
312
313/// M223 — M170 post-add side-effects deferred from the recv arm to the
314/// commit arm. Both are applied via `apply_post_add_m170` after the
315/// torrent is inserted into `self.torrents`.
316struct M170PostAdd {
317    category: Option<String>,
318    paused: bool,
319}
320
321/// M253 (D7), widened at M254: per-add overrides consolidated into one
322/// threading struct (M171 tags pattern). Config-baked knobs apply via
323/// `bake_into` before actor construction; `auto_managed` is SESSION-ENTRY
324/// state — carried through to entry construction, deliberately NOT baked.
325/// Restore pins values from the resume file (M252 D3 — never re-derive
326/// from live settings). `None` = keep the config's derived value.
327#[derive(Debug, Clone, Default)]
328struct AddConfigOverrides {
329    content_layout: Option<irontide_core::ContentLayout>,
330    sequential_download: Option<bool>,
331    prioritize_first_last_pieces: Option<bool>,
332    /// M254: preallocation override (baked into config).
333    preallocate_mode: Option<irontide_storage::PreallocateMode>,
334    /// M254: inline per-file priorities (baked into config; empty = none).
335    file_priorities: Vec<irontide_core::FilePriority>,
336    /// M254: session-entry auto-managed override — consumed at ENTRY
337    /// construction, deliberately NOT applied by `bake_into` (it is not
338    /// a `TorrentConfig` field).
339    auto_managed: Option<bool>,
340}
341
342impl AddConfigOverrides {
343    /// Apply the overrides onto a freshly derived [`TorrentConfig`].
344    fn bake_into(&self, config: &mut TorrentConfig) {
345        if let Some(layout) = self.content_layout {
346            config.content_layout = layout;
347        }
348        if let Some(seq) = self.sequential_download {
349            config.sequential_download = seq;
350        }
351        if let Some(fl) = self.prioritize_first_last_pieces {
352            config.prioritize_first_last_pieces = fl;
353        }
354        if let Some(mode) = self.preallocate_mode {
355            config.preallocate_mode = mode;
356        }
357        if !self.file_priorities.is_empty() {
358            config.file_priorities.clone_from(&self.file_priorities);
359        }
360    }
361}
362
363/// M223 — snapshot of session state needed by the off-actor add-torrent
364/// prep phase. Built synchronously on the actor at the recv arm via
365/// `SessionActor::build_add_torrent_prep_bundle`; consumed by the
366/// spawned task that runs the heavy `disk_manager.register_torrent` +
367/// `TorrentHandle::from_torrent` work without blocking the actor's
368/// command queue.
369struct AddTorrentPrepBundle {
370    torrent_meta: irontide_core::TorrentMeta,
371    storage_override: Option<Arc<dyn TorrentStorage>>,
372    torrent_config: TorrentConfig,
373    disk_manager: crate::disk::DiskManagerHandle,
374    dht_v4_broadcast: irontide_dht::DhtBroadcast,
375    dht_v6_broadcast: irontide_dht::DhtBroadcast,
376    global_up: Option<SharedBucket>,
377    global_down: Option<SharedBucket>,
378    slot_tuner: crate::slot_tuner::SlotTuner,
379    alert_tx: broadcast::Sender<Alert>,
380    alert_mask: Arc<AtomicU32>,
381    utp_socket: Option<irontide_utp::UtpSocket>,
382    utp_socket_v6: Option<irontide_utp::UtpSocket>,
383    ban_manager: SharedBanManager,
384    ip_filter: SharedIpFilter,
385    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
386    sam_session: Option<Arc<crate::i2p::SamSession>>,
387    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
388    factory: Arc<crate::transport::NetworkFactory>,
389    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
390    counters: Arc<crate::stats::SessionCounters>,
391    m170_post: Option<M170PostAdd>,
392    /// M254 (D2): session-entry auto-managed override carried through the
393    /// prep task into `PreparedAddTorrent`.
394    auto_managed: Option<bool>,
395}
396
397impl TorrentEntry {
398    /// Returns `true` if this torrent has the private flag set (BEP 27).
399    ///
400    /// v0.173.1: now queries the `TorrentActor` for current metadata. Magnet
401    /// torrents previously silently returned `false` because the session
402    /// cache (`TorrentEntry.meta`) was permanently `None` for them — BEP 27
403    /// enforcement was bypassed and peer IPs leaked to DHT/LSD. This method
404    /// returning `false` pre-metadata is still correct per the plan's
405    /// Failure-Modes table: the info dict doesn't exist yet, so the flag
406    /// cannot be enforced. Once metadata resolves, subsequent calls see the
407    /// real `info.private` value.
408    async fn is_private(&self) -> bool {
409        match self.handle.get_meta().await {
410            Ok(Some(meta)) => meta.info.private == Some(1),
411            // Pre-metadata or actor shut down: treat as non-private. A magnet
412            // that hasn't resolved yet can't enforce privacy per BEP 27 — the
413            // flag lives in the info dict, which doesn't exist yet.
414            _ => false,
415        }
416    }
417}
418
419/// Commands sent from `SessionHandle` to `SessionActor`.
420enum SessionCommand {
421    AddTorrent {
422        meta: Box<irontide_core::TorrentMeta>,
423        storage: Option<Arc<dyn TorrentStorage>>,
424        download_dir: Option<PathBuf>,
425        reply: oneshot::Sender<crate::Result<Id20>>,
426    },
427    /// M223 — internal feedback variant. Carries the result of off-actor
428    /// `handle_add_torrent` work (TCP bind + disk register + actor spawn)
429    /// back to the actor's recv loop for the mutating fixup
430    /// (insert into `self.torrents` + queue position + alert + LSD).
431    /// Decouples the actor recv loop from per-add latency so the
432    /// parallel-7 POST tail does not stack linearly with already-added
433    /// torrents. Not part of the public `SessionHandle` API — only
434    /// `add_torrent_via_spawn` (the recv-arm helper) emits this variant.
435    CommitAddTorrent {
436        result: crate::Result<PreparedAddTorrent>,
437        reply: oneshot::Sender<crate::Result<Id20>>,
438    },
439    AddMagnet {
440        magnet: Magnet,
441        download_dir: Option<PathBuf>,
442        reply: oneshot::Sender<crate::Result<Id20>>,
443    },
444    RemoveTorrent {
445        info_hash: Id20,
446        reply: oneshot::Sender<crate::Result<()>>,
447    },
448    PauseTorrent {
449        info_hash: Id20,
450        reply: oneshot::Sender<crate::Result<()>>,
451    },
452    ResumeTorrent {
453        info_hash: Id20,
454        reply: oneshot::Sender<crate::Result<()>>,
455    },
456    ForceResumeTorrent {
457        info_hash: Id20,
458        reply: oneshot::Sender<crate::Result<()>>,
459    },
460    SetTorrentSeedRatio {
461        info_hash: Id20,
462        limit: Option<f64>,
463        reply: oneshot::Sender<crate::Result<()>>,
464    },
465    TorrentStats {
466        info_hash: Id20,
467        reply: oneshot::Sender<crate::Result<TorrentStats>>,
468    },
469    TorrentInfo {
470        info_hash: Id20,
471        reply: oneshot::Sender<crate::Result<TorrentInfo>>,
472    },
473    ListTorrents {
474        reply: oneshot::Sender<Vec<Id20>>,
475    },
476    SessionStats {
477        reply: oneshot::Sender<SessionStats>,
478    },
479    SaveTorrentResumeData {
480        info_hash: Id20,
481        reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
482    },
483    SaveSessionState {
484        reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
485    },
486    /// Load and restore torrents from per-torrent resume files on disk (M161).
487    LoadResumeState {
488        reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
489    },
490    QueuePosition {
491        info_hash: Id20,
492        reply: oneshot::Sender<crate::Result<i32>>,
493    },
494    SetQueuePosition {
495        info_hash: Id20,
496        pos: i32,
497        reply: oneshot::Sender<crate::Result<()>>,
498    },
499    /// M254: include or exclude a torrent from session queue auto-management.
500    SetAutoManaged {
501        info_hash: Id20,
502        enabled: bool,
503        reply: oneshot::Sender<crate::Result<()>>,
504    },
505    QueuePositionUp {
506        info_hash: Id20,
507        reply: oneshot::Sender<crate::Result<()>>,
508    },
509    QueuePositionDown {
510        info_hash: Id20,
511        reply: oneshot::Sender<crate::Result<()>>,
512    },
513    QueuePositionTop {
514        info_hash: Id20,
515        reply: oneshot::Sender<crate::Result<()>>,
516    },
517    QueuePositionBottom {
518        info_hash: Id20,
519        reply: oneshot::Sender<crate::Result<()>>,
520    },
521    BanPeer {
522        ip: IpAddr,
523        reply: oneshot::Sender<()>,
524    },
525    UnbanPeer {
526        ip: IpAddr,
527        reply: oneshot::Sender<bool>,
528    },
529    BannedPeers {
530        reply: oneshot::Sender<Vec<IpAddr>>,
531    },
532    SetIpFilter {
533        filter: crate::ip_filter::IpFilter,
534        reply: oneshot::Sender<()>,
535    },
536    GetIpFilter {
537        reply: oneshot::Sender<crate::ip_filter::IpFilter>,
538    },
539    GetSettings {
540        reply: oneshot::Sender<Settings>,
541    },
542    ApplySettings {
543        settings: Box<Settings>,
544        reply: oneshot::Sender<crate::Result<()>>,
545    },
546    MoveTorrentStorage {
547        info_hash: Id20,
548        new_path: std::path::PathBuf,
549        reply: oneshot::Sender<crate::Result<()>>,
550    },
551    AddPeers {
552        info_hash: Id20,
553        peers: Vec<SocketAddr>,
554        source: crate::peer_state::PeerSource,
555        reply: oneshot::Sender<crate::Result<()>>,
556    },
557    OpenFile {
558        info_hash: Id20,
559        file_index: usize,
560        reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
561    },
562    ForceReannounce {
563        info_hash: Id20,
564        reply: oneshot::Sender<crate::Result<()>>,
565    },
566    TrackerList {
567        info_hash: Id20,
568        reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
569    },
570    /// M178 Lane B3 / TODO-2: `(pex_peer_count, lsd_peer_count)` for the
571    /// given torrent. Used by qBt v2 trackers endpoint + GUI Trackers tab
572    /// to populate the PeX/LSD pseudo-tracker rows with real counts.
573    GetPeerSourceCounts {
574        info_hash: Id20,
575        reply: oneshot::Sender<crate::Result<(usize, usize)>>,
576    },
577    /// Per-peer cumulative unchoke duration for a torrent. `None` reply
578    /// when the torrent does not exist (explicit contract — distinguishes
579    /// "torrent missing" from "torrent exists with empty map").
580    QueryUnchokeDurations {
581        info_hash: Id20,
582        reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
583    },
584    /// M178 Lane C: per-URL web-seed stats for the qBt v2 webseeds endpoint
585    /// and the GUI HTTP Sources tab.
586    GetWebSeedStats {
587        info_hash: Id20,
588        reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
589    },
590    Scrape {
591        info_hash: Id20,
592        reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
593    },
594    SetFilePriority {
595        info_hash: Id20,
596        index: usize,
597        priority: irontide_core::FilePriority,
598        reply: oneshot::Sender<crate::Result<()>>,
599    },
600    FilePriorities {
601        info_hash: Id20,
602        reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
603    },
604    SetDownloadLimit {
605        info_hash: Id20,
606        bytes_per_sec: u64,
607        reply: oneshot::Sender<crate::Result<()>>,
608    },
609    SetUploadLimit {
610        info_hash: Id20,
611        bytes_per_sec: u64,
612        reply: oneshot::Sender<crate::Result<()>>,
613    },
614    DownloadLimit {
615        info_hash: Id20,
616        reply: oneshot::Sender<crate::Result<u64>>,
617    },
618    UploadLimit {
619        info_hash: Id20,
620        reply: oneshot::Sender<crate::Result<u64>>,
621    },
622    SetSequentialDownload {
623        info_hash: Id20,
624        enabled: bool,
625        reply: oneshot::Sender<crate::Result<()>>,
626    },
627    IsSequentialDownload {
628        info_hash: Id20,
629        reply: oneshot::Sender<crate::Result<bool>>,
630    },
631    /// M253/ER2: enable or disable first/last-pieces-first ordering.
632    SetPrioritizeFirstLastPieces {
633        info_hash: Id20,
634        enabled: bool,
635        reply: oneshot::Sender<crate::Result<()>>,
636    },
637    /// M253/ER2: query first/last-pieces-first ordering.
638    IsPrioritizeFirstLastPieces {
639        info_hash: Id20,
640        reply: oneshot::Sender<crate::Result<bool>>,
641    },
642    SetSuperSeeding {
643        info_hash: Id20,
644        enabled: bool,
645        reply: oneshot::Sender<crate::Result<()>>,
646    },
647    IsSuperSeeding {
648        info_hash: Id20,
649        reply: oneshot::Sender<crate::Result<bool>>,
650    },
651    /// Enable or disable user-requested seed-only mode for a torrent (M159).
652    SetSeedMode {
653        info_hash: Id20,
654        enabled: bool,
655        reply: oneshot::Sender<crate::Result<()>>,
656    },
657    AddTracker {
658        info_hash: Id20,
659        url: String,
660        reply: oneshot::Sender<crate::Result<()>>,
661    },
662    ReplaceTrackers {
663        info_hash: Id20,
664        urls: Vec<String>,
665        reply: oneshot::Sender<crate::Result<()>>,
666    },
667    /// Trigger a full piece verification (force recheck) for a torrent.
668    ForceRecheck {
669        info_hash: Id20,
670        reply: oneshot::Sender<crate::Result<()>>,
671    },
672    /// Rename a file within a torrent on disk.
673    RenameFile {
674        info_hash: Id20,
675        file_index: usize,
676        new_name: String,
677        reply: oneshot::Sender<crate::Result<()>>,
678    },
679    /// Set per-torrent maximum connections (0 = use global default).
680    SetMaxConnections {
681        info_hash: Id20,
682        limit: usize,
683        reply: oneshot::Sender<crate::Result<()>>,
684    },
685    /// Get per-torrent maximum connection limit.
686    MaxConnections {
687        info_hash: Id20,
688        reply: oneshot::Sender<crate::Result<usize>>,
689    },
690    /// Set per-torrent maximum upload slots (unchoke slots).
691    SetMaxUploads {
692        info_hash: Id20,
693        limit: usize,
694        reply: oneshot::Sender<crate::Result<()>>,
695    },
696    /// Get per-torrent maximum upload slots (unchoke slots).
697    MaxUploads {
698        info_hash: Id20,
699        reply: oneshot::Sender<crate::Result<usize>>,
700    },
701    /// Get per-peer details for all connected peers of a torrent.
702    GetPeerInfo {
703        info_hash: Id20,
704        reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
705    },
706    /// Get in-flight piece download status for a torrent.
707    GetDownloadQueue {
708        info_hash: Id20,
709        reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
710    },
711    /// Check whether a specific piece has been downloaded.
712    HavePiece {
713        info_hash: Id20,
714        index: u32,
715        reply: oneshot::Sender<crate::Result<bool>>,
716    },
717    /// Get per-piece availability counts from connected peers.
718    PieceAvailability {
719        info_hash: Id20,
720        reply: oneshot::Sender<crate::Result<Vec<u32>>>,
721    },
722    /// Get per-file bytes-downloaded progress.
723    FileProgress {
724        info_hash: Id20,
725        reply: oneshot::Sender<crate::Result<Vec<u64>>>,
726    },
727    /// Get the torrent's identity hashes (v1 and/or v2).
728    InfoHashesQuery {
729        info_hash: Id20,
730        reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
731    },
732    /// Get the full v1 metainfo for a torrent.
733    TorrentFile {
734        info_hash: Id20,
735        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
736    },
737    /// Get the full v2 metainfo for a torrent.
738    TorrentFileV2 {
739        info_hash: Id20,
740        reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
741    },
742    /// Force an immediate DHT announce for a torrent.
743    ForceDhtAnnounce {
744        info_hash: Id20,
745        reply: oneshot::Sender<crate::Result<()>>,
746    },
747    /// Force an immediate LSD announce for a torrent (session-level only).
748    ForceLsdAnnounce {
749        info_hash: Id20,
750        reply: oneshot::Sender<crate::Result<()>>,
751    },
752    /// Read all data for a specific piece from disk.
753    ReadPiece {
754        info_hash: Id20,
755        index: u32,
756        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
757    },
758    /// Flush the disk write cache for a torrent.
759    FlushCache {
760        info_hash: Id20,
761        reply: oneshot::Sender<crate::Result<()>>,
762    },
763    /// Check if a torrent handle is still valid (torrent exists and channel open).
764    IsValid {
765        info_hash: Id20,
766        reply: oneshot::Sender<bool>,
767    },
768    /// Clear error state on a torrent.
769    ClearError {
770        info_hash: Id20,
771        reply: oneshot::Sender<crate::Result<()>>,
772    },
773    /// Get per-file open/mode status for a torrent.
774    FileStatus {
775        info_hash: Id20,
776        reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
777    },
778    /// Read the current torrent flags.
779    Flags {
780        info_hash: Id20,
781        reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
782    },
783    /// Set (enable) the specified torrent flags.
784    SetFlags {
785        info_hash: Id20,
786        flags: crate::types::TorrentFlags,
787        reply: oneshot::Sender<crate::Result<()>>,
788    },
789    /// Unset (disable) the specified torrent flags.
790    UnsetFlags {
791        info_hash: Id20,
792        flags: crate::types::TorrentFlags,
793        reply: oneshot::Sender<crate::Result<()>>,
794    },
795    /// Immediately initiate a peer connection for a torrent.
796    ConnectPeer {
797        info_hash: Id20,
798        addr: SocketAddr,
799        reply: oneshot::Sender<crate::Result<()>>,
800    },
801    DhtPutImmutable {
802        value: Vec<u8>,
803        reply: oneshot::Sender<crate::Result<Id20>>,
804    },
805    DhtGetImmutable {
806        target: Id20,
807        reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
808    },
809    DhtPutMutable {
810        keypair_bytes: [u8; 32],
811        value: Vec<u8>,
812        seq: i64,
813        salt: Vec<u8>,
814        reply: oneshot::Sender<crate::Result<Id20>>,
815    },
816    #[allow(clippy::type_complexity)]
817    DhtGetMutable {
818        public_key: [u8; 32],
819        salt: Vec<u8>,
820        reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
821    },
822    /// Save per-torrent resume files for all dirty torrents (M161).
823    SaveResumeState {
824        reply: oneshot::Sender<crate::Result<usize>>,
825    },
826    /// Trigger an immediate session stats snapshot and alert (M50).
827    PostSessionStats,
828    // ── M170: qBt v2 *arr-minimal surface ──
829    /// Unified add entry (M170) — see `AddTorrentParams`.
830    AddTorrentM170 {
831        params: Box<AddTorrentParams>,
832        reply: oneshot::Sender<crate::Result<Id20>>,
833    },
834    /// Create a new category with the given `save_path`.
835    CreateCategory {
836        name: String,
837        save_path: PathBuf,
838        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
839    },
840    /// Update the `save_path` on an existing category.
841    EditCategory {
842        name: String,
843        save_path: PathBuf,
844        reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
845    },
846    /// Remove zero or more categories. Returns names actually removed.
847    RemoveCategories {
848        names: Vec<String>,
849        reply: oneshot::Sender<Vec<String>>,
850    },
851    /// Snapshot the current category list.
852    ListCategories {
853        reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
854    },
855    /// Create a batch of tags (M171). One reply slot per requested name
856    /// so the caller can tell which were newly created vs already-present.
857    CreateTags {
858        names: Vec<String>,
859        reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
860    },
861    /// Remove zero or more tags (M171). Returns names actually removed;
862    /// unknown names are tolerated (matches qBt idempotent `deleteTags`).
863    DeleteTags {
864        names: Vec<String>,
865        reply: oneshot::Sender<Vec<String>>,
866    },
867    /// Snapshot the current tag list (M171). Sorted.
868    ListTags {
869        reply: oneshot::Sender<Vec<String>>,
870    },
871    /// Add the given tags to each torrent in `info_hashes` (M171).
872    /// Unknown info hashes are silently skipped. The engine-layer
873    /// command is a wholesale replacement, so each torrent's tag set
874    /// is read, unioned with the requested additions, and replayed via
875    /// `TorrentHandle::set_tags`.
876    AddTagsToTorrents {
877        info_hashes: Vec<Id20>,
878        tags: Vec<String>,
879        reply: oneshot::Sender<crate::Result<()>>,
880    },
881    /// Remove the given tags from each torrent in `info_hashes` (M171).
882    /// Unknown info hashes are silently skipped.
883    RemoveTagsFromTorrents {
884        info_hashes: Vec<Id20>,
885        tags: Vec<String>,
886        reply: oneshot::Sender<crate::Result<()>>,
887    },
888    /// Remove a torrent and delete its on-disk files (qBt
889    /// `deleteFiles=true`).
890    RemoveTorrentWithFiles {
891        info_hash: Id20,
892        reply: oneshot::Sender<crate::Result<()>>,
893    },
894    /// M171 Lane B: snapshot the web seed URLs (BEP 19 + BEP 17 merged)
895    /// for a specific torrent.
896    GetWebSeeds {
897        info_hash: Id20,
898        reply: oneshot::Sender<crate::Result<Vec<String>>>,
899    },
900    /// M171 Lane B: snapshot the per-piece state array as qBt codes
901    /// (`0`/`1`/`2`) for a specific torrent.
902    GetPieceStates {
903        info_hash: Id20,
904        reply: oneshot::Sender<crate::Result<Vec<u8>>>,
905    },
906    /// M171 Lane B: paginated piece hash list for a specific torrent.
907    GetPieceHashes {
908        info_hash: Id20,
909        offset: u32,
910        limit: u32,
911        reply: oneshot::Sender<crate::Result<Vec<String>>>,
912    },
913    /// M171 D4: sum of routing-table sizes across the IPv4 and IPv6 DHT
914    /// instances. Returns 0 when neither DHT is enabled.
915    DhtNodeCount {
916        reply: oneshot::Sender<usize>,
917    },
918    /// **TEST-ONLY (v0.173.2).** Inject info-dict bytes into a torrent's
919    /// actor synchronously, returning only after the actor has processed
920    /// it. Used by integration tests in `irontide-api` (A9) that exercise
921    /// post-metadata HTTP surface without spinning up real peers.
922    /// M187: collect per-torrent and per-peer debug state for diagnostics.
923    DebugState {
924        reply: oneshot::Sender<crate::types::DebugState>,
925    },
926    #[cfg(feature = "test-util")]
927    TestInjectMetadata {
928        info_hash: Id20,
929        info_bytes: Vec<u8>,
930        reply: oneshot::Sender<crate::Result<()>>,
931    },
932    Shutdown,
933}
934
935impl SessionCommand {
936    /// Static variant name for the `cmd` field of the M221.1a
937    /// `session_cmd` tracing event. Stable across renames is *not* a
938    /// goal — this is bench-instrumentation telemetry, so the variant
939    /// identifier is the right label.
940    fn name(&self) -> &'static str {
941        match self {
942            Self::AddTorrent { .. } => "AddTorrent",
943            Self::CommitAddTorrent { .. } => "CommitAddTorrent",
944            Self::AddMagnet { .. } => "AddMagnet",
945            Self::RemoveTorrent { .. } => "RemoveTorrent",
946            Self::PauseTorrent { .. } => "PauseTorrent",
947            Self::ResumeTorrent { .. } => "ResumeTorrent",
948            Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
949            Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
950            Self::TorrentStats { .. } => "TorrentStats",
951            Self::TorrentInfo { .. } => "TorrentInfo",
952            Self::ListTorrents { .. } => "ListTorrents",
953            Self::SessionStats { .. } => "SessionStats",
954            Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
955            Self::SaveSessionState { .. } => "SaveSessionState",
956            Self::LoadResumeState { .. } => "LoadResumeState",
957            Self::QueuePosition { .. } => "QueuePosition",
958            Self::SetQueuePosition { .. } => "SetQueuePosition",
959            Self::SetAutoManaged { .. } => "SetAutoManaged",
960            Self::QueuePositionUp { .. } => "QueuePositionUp",
961            Self::QueuePositionDown { .. } => "QueuePositionDown",
962            Self::QueuePositionTop { .. } => "QueuePositionTop",
963            Self::QueuePositionBottom { .. } => "QueuePositionBottom",
964            Self::BanPeer { .. } => "BanPeer",
965            Self::UnbanPeer { .. } => "UnbanPeer",
966            Self::BannedPeers { .. } => "BannedPeers",
967            Self::SetIpFilter { .. } => "SetIpFilter",
968            Self::GetIpFilter { .. } => "GetIpFilter",
969            Self::GetSettings { .. } => "GetSettings",
970            Self::ApplySettings { .. } => "ApplySettings",
971            Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
972            Self::AddPeers { .. } => "AddPeers",
973            Self::OpenFile { .. } => "OpenFile",
974            Self::ForceReannounce { .. } => "ForceReannounce",
975            Self::TrackerList { .. } => "TrackerList",
976            Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
977            Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
978            Self::GetWebSeedStats { .. } => "GetWebSeedStats",
979            Self::Scrape { .. } => "Scrape",
980            Self::SetFilePriority { .. } => "SetFilePriority",
981            Self::FilePriorities { .. } => "FilePriorities",
982            Self::SetDownloadLimit { .. } => "SetDownloadLimit",
983            Self::SetUploadLimit { .. } => "SetUploadLimit",
984            Self::DownloadLimit { .. } => "DownloadLimit",
985            Self::UploadLimit { .. } => "UploadLimit",
986            Self::SetSequentialDownload { .. } => "SetSequentialDownload",
987            Self::IsSequentialDownload { .. } => "IsSequentialDownload",
988            Self::SetPrioritizeFirstLastPieces { .. } => "SetPrioritizeFirstLastPieces",
989            Self::IsPrioritizeFirstLastPieces { .. } => "IsPrioritizeFirstLastPieces",
990            Self::SetSuperSeeding { .. } => "SetSuperSeeding",
991            Self::IsSuperSeeding { .. } => "IsSuperSeeding",
992            Self::SetSeedMode { .. } => "SetSeedMode",
993            Self::AddTracker { .. } => "AddTracker",
994            Self::ReplaceTrackers { .. } => "ReplaceTrackers",
995            Self::ForceRecheck { .. } => "ForceRecheck",
996            Self::RenameFile { .. } => "RenameFile",
997            Self::SetMaxConnections { .. } => "SetMaxConnections",
998            Self::MaxConnections { .. } => "MaxConnections",
999            Self::SetMaxUploads { .. } => "SetMaxUploads",
1000            Self::MaxUploads { .. } => "MaxUploads",
1001            Self::GetPeerInfo { .. } => "GetPeerInfo",
1002            Self::GetDownloadQueue { .. } => "GetDownloadQueue",
1003            Self::HavePiece { .. } => "HavePiece",
1004            Self::PieceAvailability { .. } => "PieceAvailability",
1005            Self::FileProgress { .. } => "FileProgress",
1006            Self::InfoHashesQuery { .. } => "InfoHashesQuery",
1007            Self::TorrentFile { .. } => "TorrentFile",
1008            Self::TorrentFileV2 { .. } => "TorrentFileV2",
1009            Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
1010            Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
1011            Self::ReadPiece { .. } => "ReadPiece",
1012            Self::FlushCache { .. } => "FlushCache",
1013            Self::IsValid { .. } => "IsValid",
1014            Self::ClearError { .. } => "ClearError",
1015            Self::FileStatus { .. } => "FileStatus",
1016            Self::Flags { .. } => "Flags",
1017            Self::SetFlags { .. } => "SetFlags",
1018            Self::UnsetFlags { .. } => "UnsetFlags",
1019            Self::ConnectPeer { .. } => "ConnectPeer",
1020            Self::DhtPutImmutable { .. } => "DhtPutImmutable",
1021            Self::DhtGetImmutable { .. } => "DhtGetImmutable",
1022            Self::DhtPutMutable { .. } => "DhtPutMutable",
1023            Self::DhtGetMutable { .. } => "DhtGetMutable",
1024            Self::SaveResumeState { .. } => "SaveResumeState",
1025            Self::PostSessionStats => "PostSessionStats",
1026            Self::AddTorrentM170 { .. } => "AddTorrentM170",
1027            Self::CreateCategory { .. } => "CreateCategory",
1028            Self::EditCategory { .. } => "EditCategory",
1029            Self::RemoveCategories { .. } => "RemoveCategories",
1030            Self::ListCategories { .. } => "ListCategories",
1031            Self::CreateTags { .. } => "CreateTags",
1032            Self::DeleteTags { .. } => "DeleteTags",
1033            Self::ListTags { .. } => "ListTags",
1034            Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
1035            Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
1036            Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
1037            Self::GetWebSeeds { .. } => "GetWebSeeds",
1038            Self::GetPieceStates { .. } => "GetPieceStates",
1039            Self::GetPieceHashes { .. } => "GetPieceHashes",
1040            Self::DhtNodeCount { .. } => "DhtNodeCount",
1041            Self::DebugState { .. } => "DebugState",
1042            #[cfg(feature = "test-util")]
1043            Self::TestInjectMetadata { .. } => "TestInjectMetadata",
1044            Self::Shutdown => "Shutdown",
1045        }
1046    }
1047}
1048
1049/// Channel sender that timestamps each outgoing `SessionCommand` with
1050/// the instant it was enqueued, so the actor's receive arm can split
1051/// `queue_wait_ms` (sender → receiver) from `handler_ms` (dispatch).
1052/// M221.1a — feeds the parallel-7 bench harness.
1053#[derive(Clone)]
1054struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
1055
1056impl SessionCmdSender {
1057    async fn send(
1058        &self,
1059        cmd: SessionCommand,
1060    ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
1061        let sent_at = tokio::time::Instant::now();
1062        self.0
1063            .send((sent_at, cmd))
1064            .await
1065            .map_err(|e| mpsc::error::SendError(e.0.1))
1066    }
1067}
1068
1069/// Classification of settings-patch fields into "took effect immediately"
1070/// versus "requires session restart to apply".
1071///
1072/// Returned by [`SessionHandle::apply_settings_classified`]. The
1073/// `restart_required` list is surfaced as the `X-IronTide-Restart-Pending`
1074/// response header by the qBt v2 `setPreferences` handler so clients can
1075/// render a "restart to apply" UX affordance (M171 D3.5).
1076#[derive(Debug, Clone, Default)]
1077pub struct AppliedSettings {
1078    /// Settings fields that changed and took effect immediately.
1079    pub immediate: Vec<&'static str>,
1080    /// Settings fields that changed but require a session restart to
1081    /// activate (sub-actor reconfig, listen-socket rebind, DHT/LSD/PEX
1082    /// startup, encryption-handshake policy, anonymous-mode peer ID, etc.).
1083    pub restart_required: Vec<&'static str>,
1084}
1085
1086// ─────────────────────────────────────────────────────────────────────────
1087// Settings reconfiguration classification — CODEGEN from the SSOT registry
1088// (M247a). The reconfiguration class (`immediate` / `restart` / `stored`) and
1089// qBt wire-name of every setting are declared ONCE in `irontide_settings`'
1090// exported `for_each_setting!` / `for_each_qbt_compat_setting!` /
1091// `for_each_proxy_setting!` registries (crates/irontide-settings/src/schema.rs).
1092// The two `classify_*` projections below are GENERATED from those registries
1093// rather than hand-maintained, so a field can never drift out of sync between
1094// its declaration and its reconfiguration class.
1095//
1096// Mechanism (tracer-proven, M247a Task 1):
1097//   * `push_if!` emits a `v.push(wire)` guard IFF the entry's `class` matches
1098//     the target class. The class is forwarded as a `:tt` (NOT captured as an
1099//     `:ident`) — a captured `:ident` is opaque downstream and would not
1100//     re-match the literal-keyword arms (Trap 3).
1101//   * one tiny emitter per (target-class, accessor-prefix) generates an
1102//     appender fn; the registry's `$(…)*` repetition expands one `push_if!`
1103//     per field. `stored` fields (and class-mismatches) expand to nothing, so
1104//     their accessor is never even referenced.
1105//   * the `classify_*` wrappers call the appenders in sequence. The output
1106//     ORDER differs from the pre-M247a hand-written interleaving, but every
1107//     consumer and test treats the result as a SET (`.contains()` / `HashSet`
1108//     equality — see the golden tests in this module), so order is immaterial.
1109// ─────────────────────────────────────────────────────────────────────────
1110
1111/// Emits `if o.<acc> != n.<acc> { v.push(wire); }` IFF the entry's `class`
1112/// (arg 2, forwarded as `:tt`) equals the target class (arg 1). Every other
1113/// class — including `stored` — matches the final no-op arm and expands to
1114/// nothing, so a non-projected field's accessor is never referenced.
1115macro_rules! push_if {
1116    (immediate, immediate, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
1117        if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
1118    };
1119    (restart, restart, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {
1120        if $o.$($acc)* != $n.$($acc)* { $v.push($wire); }
1121    };
1122    ($t:tt, $c:tt, $o:ident, $n:ident, ($($acc:tt)*), $wire:literal, $v:ident) => {};
1123}
1124
1125// One appender per (target-class, accessor-prefix). Each emitter generates a
1126// free fn over its whole registry; `push_if!` keeps only the matching class.
1127macro_rules! emit_immediate_top {
1128    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1129        fn append_immediate_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1130            $( push_if!(immediate, $class, o, n, ($name), $wire, v); )*
1131        }
1132    };
1133}
1134irontide_settings::for_each_setting!(emit_immediate_top);
1135
1136macro_rules! emit_immediate_qbt {
1137    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1138        fn append_immediate_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1139            $( push_if!(immediate, $class, o, n, (qbt_compat . $name), $wire, v); )*
1140        }
1141    };
1142}
1143irontide_settings::for_each_qbt_compat_setting!(emit_immediate_qbt);
1144
1145macro_rules! emit_restart_top {
1146    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1147        fn append_restart_top(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1148            $( push_if!(restart, $class, o, n, ($name), $wire, v); )*
1149        }
1150    };
1151}
1152irontide_settings::for_each_setting!(emit_restart_top);
1153
1154macro_rules! emit_restart_qbt {
1155    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1156        fn append_restart_qbt(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1157            $( push_if!(restart, $class, o, n, (qbt_compat . $name), $wire, v); )*
1158        }
1159    };
1160}
1161irontide_settings::for_each_qbt_compat_setting!(emit_restart_qbt);
1162
1163macro_rules! emit_restart_proxy {
1164    ( $({ name: $name:ident, class: $class:tt, wire: $wire:literal, doc: $doc:literal })* ) => {
1165        fn append_restart_proxy(o: &Settings, n: &Settings, v: &mut Vec<&'static str>) {
1166            $( push_if!(restart, $class, o, n, (proxy . $name), $wire, v); )*
1167        }
1168    };
1169}
1170irontide_settings::for_each_proxy_setting!(emit_restart_proxy);
1171
1172/// Classify fields whose runtime application is immediate — rate limiters
1173/// (M166), alert mask, peer cap, and enum/flag state that peer/torrent
1174/// actors re-read on every tick.
1175///
1176/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, and `lsd`
1177/// move from `restart_required` to `immediate`. The transactional
1178/// apply pipeline (B1) now performs the live reconfig:
1179/// - `listen_port`: TCP listener rebind + uTP `shutdown_and_wait` + new
1180///   bind + NAT `refresh_for_port` (B2/B4/B8 primitives).
1181/// - dht: `shutdown_and_wait` (B7 persists routing table) + new
1182///   `DhtHandle` + `DhtBroadcast::replace` fans out to torrents (B5+B6).
1183/// - lsd: `shutdown_and_wait` (B9 multicast fd guard) + new actor.
1184///
1185/// **Body generated (M247a)** from `for_each_setting!` +
1186/// `for_each_qbt_compat_setting!` via `append_immediate_top` /
1187/// `append_immediate_qbt`.
1188///
1189/// `[REGRESSION CRITICAL]`: the wire-format pinned test in irontide-client's
1190/// `crates/irontide-api/tests/qbt_v2_set_preferences.rs` (cross-repo; runs
1191/// against the published crate) asserts the EXACT field-name set here.
1192/// Downstream *arr clients parse the `X-IronTide-Restart-Pending` header — a
1193/// silent rename = downstream regression. Change a `wire:` in the registry
1194/// only with that contract in mind; never reuse a field name.
1195fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
1196    let mut v = Vec::new();
1197    append_immediate_top(old, new, &mut v);
1198    append_immediate_qbt(old, new, &mut v);
1199    v
1200}
1201
1202/// Classify fields whose runtime application STILL requires a session
1203/// restart, post-M173 Lane B graduation:
1204/// - PEX (peer announcement on next handshake — propagation cost)
1205/// - encryption handshake policy (MSE bytes-on-wire)
1206/// - anonymous-mode peer ID
1207/// - download-dir root (in-flight torrents retain their original
1208///   `save_path`; only `next-add` changes — this is intentional, not
1209///   a bug, see HA spec non-goals)
1210///
1211/// **M173 Lane B (B10) — graduation:** `listen_port`, `dht`, `lsd` were
1212/// removed from this list; they now appear in [`classify_immediate`].
1213/// The transactional apply pipeline (B1) performs their live reconfig.
1214///
1215/// **Body generated (M247a)** from `for_each_setting!` +
1216/// `for_each_qbt_compat_setting!` + `for_each_proxy_setting!` via
1217/// `append_restart_top` / `append_restart_qbt` / `append_restart_proxy`.
1218fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1219    let mut v = Vec::new();
1220    append_restart_top(old, new, &mut v);
1221    append_restart_qbt(old, new, &mut v);
1222    append_restart_proxy(old, new, &mut v);
1223    v
1224}
1225
1226/// Cloneable handle for interacting with a running session.
1227#[derive(Clone)]
1228pub struct SessionHandle {
1229    cmd_tx: SessionCmdSender,
1230    alert_tx: broadcast::Sender<Alert>,
1231    alert_mask: Arc<AtomicU32>,
1232    counters: Arc<crate::stats::SessionCounters>,
1233    /// Network transport factory (M51). Used by future simulation tasks.
1234    #[allow(dead_code)]
1235    factory: Arc<crate::transport::NetworkFactory>,
1236    /// M173 Lane B (B11): in-flight guard for concurrent
1237    /// `apply_settings` / `apply_settings_classified` calls. The
1238    /// guard is acquired BEFORE the command is queued; if a
1239    /// previous reconfig is still in flight, the second caller hits
1240    /// `Error::ConcurrentReconfig` (HTTP 409 Conflict on the qBt
1241    /// v2 `setPreferences` endpoint) instead of racing the first
1242    /// caller's read-modify-write.
1243    reconfig_in_flight: crate::apply::ReconfigInFlight,
1244    /// M245 A1 — lock-free published read-model. Read-only callers
1245    /// ([`list_torrent_summaries`](Self::list_torrent_summaries)) load an
1246    /// `Arc` snapshot here instead of round-tripping the command mailbox.
1247    /// The [`SessionActor`] is the SOLE writer: it patches membership
1248    /// eagerly on add/remove and refreshes sampled stats once per stats
1249    /// tick. Same `ArcSwap` the actor holds (cloned at construction).
1250    snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
1251}
1252
1253impl SessionHandle {
1254    /// Start a new session with the given settings and no plugins.
1255    ///
1256    /// # Errors
1257    ///
1258    /// Returns an error if the connection or binding fails.
1259    pub async fn start(settings: Settings) -> crate::Result<Self> {
1260        Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1261    }
1262
1263    /// Start a new session with a custom disk I/O backend and no plugins.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns an error if the connection or binding fails.
1268    pub async fn start_with_backend(
1269        settings: Settings,
1270        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1271    ) -> crate::Result<Self> {
1272        Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1273    }
1274
1275    /// Start a new session with the given settings and extension plugins.
1276    ///
1277    /// # Errors
1278    ///
1279    /// Returns an error if the connection or binding fails.
1280    pub async fn start_with_plugins(
1281        settings: Settings,
1282        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1283    ) -> crate::Result<Self> {
1284        let disk_config = crate::disk::DiskConfig::from(&settings);
1285        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1286        Self::start_with_plugins_and_backend(settings, plugins, backend).await
1287    }
1288
1289    /// Start a new session with the given settings, extension plugins, and
1290    /// a custom disk I/O backend.
1291    ///
1292    /// # Errors
1293    ///
1294    /// Returns an error if the connection or binding fails.
1295    pub async fn start_with_plugins_and_backend(
1296        settings: Settings,
1297        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1298        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1299    ) -> crate::Result<Self> {
1300        Self::start_full(
1301            settings,
1302            plugins,
1303            backend,
1304            Arc::new(crate::transport::NetworkFactory::tokio()),
1305        )
1306        .await
1307    }
1308
1309    /// Start a new session with the given settings and a custom transport factory.
1310    ///
1311    /// Uses default plugins (none) and default disk backend.
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns an error if the connection or binding fails.
1316    pub async fn start_with_transport(
1317        settings: Settings,
1318        factory: Arc<crate::transport::NetworkFactory>,
1319    ) -> crate::Result<Self> {
1320        let disk_config = crate::disk::DiskConfig::from(&settings);
1321        let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1322        Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1323    }
1324
1325    /// Start a new session with all customizable parameters.
1326    ///
1327    /// This is the most general constructor — all other `start_*` variants
1328    /// delegate to this method. The `factory` parameter controls how TCP
1329    /// listeners and connections are created: use [`crate::transport::NetworkFactory::tokio()`]
1330    /// for real networking or a custom factory for simulation.
1331    ///
1332    /// # Errors
1333    ///
1334    /// Returns an error if the connection or binding fails.
1335    pub async fn start_full(
1336        settings: Settings,
1337        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1338        backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1339        factory: Arc<crate::transport::NetworkFactory>,
1340    ) -> crate::Result<Self> {
1341        let mut settings = settings;
1342
1343        // Force proxy mode: all connections must go through proxy.
1344        if settings.force_proxy {
1345            if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1346                return Err(crate::Error::Config(
1347                    "force_proxy requires a proxy to be configured".into(),
1348                ));
1349            }
1350            settings.enable_upnp = false;
1351            settings.enable_natpmp = false;
1352            settings.enable_dht = false;
1353            settings.enable_lsd = false;
1354        }
1355
1356        // Anonymous mode: suppress identity and disable discovery.
1357        if settings.anonymous_mode {
1358            settings.enable_dht = false;
1359            settings.enable_lsd = false;
1360            settings.enable_upnp = false;
1361            settings.enable_natpmp = false;
1362        }
1363
1364        // M172a A3/C2: legacy-plaintext → argon2id migration on the in-memory
1365        // Settings. The config file rewrite is a separate concern handled by
1366        // the CLI layer (see irontide_config::migrate_qbt_credentials_in_file)
1367        // — we only mutate the runtime copy here so login always uses PHC
1368        // hashes regardless of on-disk state.
1369        //
1370        // Failure semantics (C2): on hash error we keep the plaintext in
1371        // memory so verify still works, and emit a WARN. The daemon continues
1372        // — a startup crash would brick an operator on a transient OS error.
1373        match irontide_settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1374            Ok(irontide_settings::QbtCredentialMigration::Upgraded) => {
1375                warn!(
1376                    "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1377                     persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1378                     next config-touching CLI command to remove the plaintext from disk"
1379                );
1380            }
1381            Ok(irontide_settings::QbtCredentialMigration::NoOp) => {}
1382            Err(e) => {
1383                warn!(
1384                    error = %e,
1385                    "qbt_compat: in-memory password migration failed — continuing with \
1386                     legacy plaintext; retry on next daemon start"
1387                );
1388            }
1389        }
1390
1391        let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1392        let cmd_tx = SessionCmdSender(raw_cmd_tx);
1393
1394        // Alert broadcast channel
1395        let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1396        let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1397
1398        // M226 Step 5: spawn the engine-side OS notification dispatcher.
1399        // The dispatcher subscribes to the alert broadcast BEFORE any
1400        // TorrentAdded can fire (H5 — eliminates the missed-cache race
1401        // on first add), reads `notify_on_complete` / `notify_on_error`
1402        // live from a watch channel mirrored by `handle_apply_settings`,
1403        // and exits when either the `notification_shutdown_tx` field on
1404        // `SessionActor` drops OR the alert broadcast closes. Production
1405        // uses `LibNotifySink` (wraps `notify-rust` via spawn_blocking);
1406        // first D-Bus failure logs a single WARN then degrades silently.
1407        let (notification_settings_tx, notification_settings_rx) =
1408            tokio::sync::watch::channel(settings.clone());
1409        let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1410        let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1411            crate::notification::DispatcherOptions {
1412                sink: Box::new(crate::notification::LibNotifySink::new()),
1413                settings_rx: notification_settings_rx,
1414                alerts_rx: alert_tx.subscribe(),
1415                shutdown_rx: notification_shutdown_rx,
1416            },
1417        );
1418
1419        // M226 Step 6: prepare the watched-folder dispatcher's channels.
1420        // The dispatcher itself is spawned at the very end of `start_full`
1421        // (it needs a fully-built `SessionHandle` to clone), but the
1422        // shutdown signal + change-notify must exist before SessionActor
1423        // construction so they can be stored on the actor.
1424        let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1425        let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) = oneshot::channel::<()>();
1426        // Fresh settings subscription dedicated to the watcher (a watch
1427        // Receiver is single-consumer; the notification dispatcher
1428        // already consumed the original).
1429        let watched_folder_settings_rx = notification_settings_tx.subscribe();
1430
1431        let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1432            match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1433                Ok((handle, rx)) => (Some(handle), Some(rx)),
1434                Err(e) => {
1435                    warn!("LSD unavailable (port 6771): {e}");
1436                    (None, None)
1437                }
1438            }
1439        } else {
1440            (None, None)
1441        };
1442
1443        let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1444            crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1445        ));
1446        let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1447            crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1448        ));
1449
1450        // M225 G4: build the shared admit-gate state BEFORE binding the uTP
1451        // socket so the SocketActor's inbound-SYN path can read from the
1452        // same `Arc<AtomicI32>` / `Arc<AtomicUsize>` / `SharedIpFilter` the
1453        // TCP listener and `handle_apply_settings` use. These three
1454        // allocations are otherwise constructed further down (lines below);
1455        // hoisting them avoids a separate setter on `UtpSocket` and the
1456        // race window it would open.
1457        let ip_filter: SharedIpFilter =
1458            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1459        let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1460            settings.max_connections_global,
1461        ));
1462        let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1463
1464        let utp_admit = {
1465            let ip_filter_for_utp = Arc::clone(&ip_filter);
1466            irontide_utp::AdmitGate::new(
1467                Arc::clone(&max_connections_global),
1468                Arc::clone(&live_connections),
1469                Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1470            )
1471        };
1472
1473        // uTP socket (shared across all torrents).
1474        //
1475        // Stage U: when the factory exposes a `bind_udp` closure (sim
1476        // path), bind the production `UtpSocket` on top of the factory's
1477        // `UdpTransport` so BEP 29 runs unmodified through the in-memory
1478        // packet bus. The tokio factory leaves `bind_udp` unset and the
1479        // direct `UtpSocket::bind` path runs (it owns the FD-level DSCP /
1480        // TCLASS setsockopt, which the sim has no equivalent for).
1481        let (utp_socket, utp_listener) = if settings.enable_utp {
1482            let utp_config = settings.to_utp_config(settings.listen_port);
1483            let bind_addr = utp_config.bind_addr;
1484            let result = if factory.has_bind_udp() {
1485                match factory.bind_udp(bind_addr).await {
1486                    Ok(transport) => irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1487                        transport,
1488                        utp_config,
1489                        utp_admit.clone(),
1490                    ),
1491                    Err(e) => Err(irontide_utp::Error::Io(e)),
1492                }
1493            } else {
1494                irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1495            };
1496            match result {
1497                Ok((socket, listener)) => (Some(socket), Some(listener)),
1498                Err(e) => {
1499                    warn!("uTP bind failed: {e}");
1500                    (None, None)
1501                }
1502            }
1503        } else {
1504            (None, None)
1505        };
1506
1507        // IPv6 uTP socket (dual-stack). Sim path skips IPv6 — the sim
1508        // network only models v4 today.
1509        let (utp_socket_v6, utp_listener_v6) =
1510            if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1511                match irontide_utp::UtpSocket::bind_with_admit_gate(
1512                    settings.to_utp_config_v6(settings.listen_port),
1513                    utp_admit.clone(),
1514                )
1515                .await
1516                {
1517                    Ok((socket, listener)) => (Some(socket), Some(listener)),
1518                    Err(e) => {
1519                        debug!("uTP IPv6 bind failed (non-fatal): {e}");
1520                        (None, None)
1521                    }
1522                }
1523            } else {
1524                (None, None)
1525            };
1526
1527        // NAT port mapping (PCP / NAT-PMP / UPnP)
1528        let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1529            let nat_config = settings.to_nat_config();
1530            let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1531            let udp_port = if settings.enable_utp {
1532                Some(settings.listen_port)
1533            } else {
1534                None
1535            };
1536            handle.map_ports(settings.listen_port, udp_port).await;
1537            (Some(handle), Some(events_rx))
1538        } else {
1539            (None, None)
1540        };
1541
1542        // I2P SAM session
1543        let sam_session = if settings.enable_i2p {
1544            let tunnel_config = settings.to_sam_tunnel_config();
1545            match crate::i2p::SamSession::create(
1546                &settings.i2p_hostname,
1547                settings.i2p_port,
1548                "torrent",
1549                tunnel_config,
1550            )
1551            .await
1552            {
1553                Ok(session) => {
1554                    let b32 = session.destination().to_b32_address();
1555                    info!("I2P SAM session created: {}", b32);
1556                    post_alert(
1557                        &alert_tx,
1558                        &alert_mask,
1559                        AlertKind::I2pSessionCreated { b32_address: b32 },
1560                    );
1561                    Some(Arc::new(session))
1562                }
1563                Err(e) => {
1564                    warn!("I2P SAM session failed: {e}");
1565                    post_alert(
1566                        &alert_tx,
1567                        &alert_mask,
1568                        AlertKind::I2pError {
1569                            message: format!("SAM session creation failed: {e}"),
1570                        },
1571                    );
1572                    None
1573                }
1574            }
1575        } else {
1576            None
1577        };
1578
1579        // SSL manager (M42): create if ssl_listen_port != 0 or cert paths are provided
1580        let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1581            match crate::ssl_manager::SslManager::new(&settings) {
1582                Ok(mgr) => {
1583                    info!("SSL manager initialized");
1584                    Some(Arc::new(mgr))
1585                }
1586                Err(e) => {
1587                    warn!(error = %e, "SSL manager initialization failed");
1588                    None
1589                }
1590            }
1591        } else {
1592            None
1593        };
1594
1595        // TCP listener: bind on the main listen port for incoming peer connections.
1596        let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1597            .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1598            .await
1599        {
1600            Ok(l) => {
1601                info!(port = settings.listen_port, "TCP listener started");
1602                Some(l)
1603            }
1604            Err(e) => {
1605                warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1606                None
1607            }
1608        };
1609
1610        // SSL listener (M42): bind if ssl_listen_port != 0
1611        let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1612            .ssl_listen_port
1613            != 0
1614        {
1615            match factory
1616                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1617                .await
1618            {
1619                Ok(l) => {
1620                    info!(port = settings.ssl_listen_port, "SSL listener started");
1621                    Some(l)
1622                }
1623                Err(e) => {
1624                    warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1625                    None
1626                }
1627            }
1628        } else {
1629            None
1630        };
1631
1632        // Start DHT instances
1633        let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1634            match DhtHandle::start(settings.to_dht_config()).await {
1635                Ok((handle, ip_rx)) => {
1636                    info!("DHT v4 started");
1637                    (Some(handle), Some(ip_rx))
1638                }
1639                Err(e) => {
1640                    warn!("DHT v4 start failed: {e}");
1641                    (None, None)
1642                }
1643            }
1644        } else {
1645            (None, None)
1646        };
1647
1648        let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1649            match DhtHandle::start(settings.to_dht_config_v6()).await {
1650                Ok((handle, ip_rx)) => {
1651                    info!("DHT v6 started");
1652                    (Some(handle), Some(ip_rx))
1653                }
1654                Err(e) => {
1655                    debug!("DHT v6 start failed (non-fatal): {e}");
1656                    (None, None)
1657                }
1658            }
1659        } else {
1660            (None, None)
1661        };
1662
1663        // M173 Lane B (B6): seed the broadcast surfaces with the
1664        // initial DHT handles so consumers see exactly the same
1665        // value via the broadcast as the legacy `dht_v4`/`dht_v6`
1666        // fields. B11's apply_settings DHT-restart phase later
1667        // updates the broadcasts via `replace`.
1668        let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1669        let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1670
1671        let ban_config = crate::ban::BanConfig::from(&settings);
1672        let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1673            crate::ban::BanManager::new(ban_config),
1674        ));
1675
1676        // M225 G4: `ip_filter` was hoisted above the uTP bind so the
1677        // SocketActor's admit-gate closure captures the same Arc.
1678
1679        let disk_config = crate::disk::DiskConfig::from(&settings);
1680        let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1681        let (disk_manager, disk_actor_handle) =
1682            crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1683
1684        let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1685            settings.enable_diagnostic_counters,
1686        ));
1687
1688        // M96: Create shared hash pool for parallel piece verification
1689        let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1690            settings.hashing_threads,
1691            64,
1692        ));
1693
1694        // M114: Spawn isolated listener task for TCP/uTP accepts.
1695        let info_hash_registry = Arc::new(DashMap::new());
1696        let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1697        // M224 D3 / M225 G4: `max_connections_global` and `live_connections`
1698        // were hoisted above the uTP bind so the SocketActor's admit gate
1699        // shares them with this listener. handle_apply_settings updates the
1700        // i32 atomic in-place; both transports read it on the next admit.
1701        let listener_task = crate::listener::ListenerTask::new(
1702            tcp_listener,
1703            utp_listener,
1704            utp_listener_v6,
1705            Arc::clone(&info_hash_registry),
1706            validated_tx,
1707            Arc::clone(&max_connections_global),
1708            Arc::clone(&live_connections),
1709        );
1710        // M173 Lane B (B2): spawn via ListenerHandle::spawn so the
1711        // shutdown channel is plumbed in. We hold the full
1712        // `ListenerHandle` (not just the JoinHandle) so the future
1713        // listen-port rebind path (B4) can call
1714        // `shutdown_with_timeout` for a clean port swap. Session
1715        // teardown still works via Drop on the `ListenerHandle` →
1716        // shutdown sender Drop → receiver fires `RecvError` → loop
1717        // exits.
1718        let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1719
1720        let external_ip = settings.external_ip;
1721
1722        // M170: load the category registry once on startup. Errors are
1723        // soft-recovered inside `CategoryRegistry::load`, so this call
1724        // cannot fail in practice.
1725        let category_registry_path = crate::category_manager::resolve_category_registry_path(
1726            settings.category_registry_path.as_deref(),
1727        );
1728        let category_registry = Arc::new(parking_lot::RwLock::new(
1729            crate::category_manager::CategoryRegistry::load(category_registry_path),
1730        ));
1731        // M171: same pattern as the category registry — soft-recover inside
1732        // `TagRegistry::load`, so this never fails in practice.
1733        let tag_registry_path =
1734            crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1735        let tag_registry = Arc::new(parking_lot::RwLock::new(
1736            crate::tag_manager::TagRegistry::load(tag_registry_path),
1737        ));
1738        let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1739        let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1740        // M245 A1 — the lock-free published read-model. Built empty (the
1741        // `torrents` map starts empty too); the actor patches it on every
1742        // add/remove and refreshes stats each tick. `Arc::clone`d into the
1743        // actor (sole writer) and moved into the handle (reader) below.
1744        let snapshot = Arc::new(arc_swap::ArcSwap::from_pointee(SessionSnapshot::default()));
1745
1746        let actor = SessionActor {
1747            // M255/ER1: build from live settings BEFORE the `settings`
1748            // shorthand below moves the binding into the struct.
1749            geoip: crate::geoip::build_geoip_resolver(&settings),
1750            settings,
1751            // M223 — clone so the actor can self-emit CommitAddTorrent
1752            // from off-actor prep tasks. The handle still owns the
1753            // original sender; both feed the same recv queue.
1754            commit_tx: cmd_tx.clone(),
1755            torrents: HashMap::new(),
1756            snapshot: Arc::clone(&snapshot),
1757            dht_v4,
1758            dht_v6,
1759            dht_v4_broadcast,
1760            dht_v6_broadcast,
1761            lsd,
1762            lsd_peers_rx,
1763            cmd_rx,
1764            alert_tx: alert_tx.clone(),
1765            alert_mask: Arc::clone(&alert_mask),
1766            global_upload_bucket,
1767            global_download_bucket,
1768            utp_socket,
1769            utp_socket_v6,
1770            nat,
1771            nat_events_rx,
1772            ban_manager,
1773            ip_filter,
1774            disk_manager,
1775            disk_actor_handle,
1776            external_ip,
1777            external_tcp_port: None,
1778            incoming_peer_connections: 0,
1779            dht_v4_ip_rx,
1780            dht_v6_ip_rx,
1781            plugins,
1782            sam_session,
1783            ssl_manager,
1784            ssl_listener,
1785            validated_conn_rx,
1786            info_hash_registry,
1787            _listener_task: listener_handle,
1788            max_connections_global,
1789            live_connections,
1790            counters: Arc::clone(&counters),
1791            factory: Arc::clone(&factory),
1792            hash_pool,
1793            category_registry,
1794            tag_registry,
1795            deletion_grace,
1796            // Clone so the SessionHandle can hold the same guard.
1797            reconfig_in_flight: reconfig_in_flight.clone(),
1798            self_alert_rx: alert_tx.subscribe(),
1799            resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1800            resume_save_lock: Arc::new(tokio::sync::Mutex::new(())),
1801            notification_settings_tx,
1802            notification_shutdown_tx,
1803            watched_folder_changed: Arc::clone(&watched_folder_changed),
1804            watched_folder_shutdown_tx,
1805        };
1806
1807        let join_handle = tokio::spawn(actor.run());
1808        tokio::spawn(async move {
1809            match join_handle.await {
1810                Ok(()) => {
1811                    tracing::warn!("session actor exited cleanly");
1812                }
1813                Err(e) if e.is_panic() => {
1814                    let panic_payload = e.into_panic();
1815                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1816                        (*s).to_string()
1817                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1818                        s.clone()
1819                    } else {
1820                        "unknown panic payload".to_string()
1821                    };
1822                    tracing::error!("session actor PANICKED: {msg}");
1823                }
1824                Err(e) => {
1825                    tracing::error!("session actor task error: {e}");
1826                }
1827            }
1828        });
1829        let handle = Self {
1830            cmd_tx,
1831            alert_tx,
1832            alert_mask,
1833            counters,
1834            factory,
1835            // M173 Lane B (B11): share the in-flight guard with the
1836            // SessionActor — the actor's apply pipeline can also
1837            // check it for symmetric guarantees, but the HANDLE-side
1838            // try_lock is what catches caller-side races (two
1839            // concurrent setPreferences requests).
1840            reconfig_in_flight,
1841            // M245 A1 — same ArcSwap the actor writes; this is the read side.
1842            snapshot,
1843        };
1844
1845        // M226 Step 6: spawn the watched-folder dispatcher AFTER the
1846        // SessionHandle is built so we can hand it a `.clone()`. The
1847        // dispatcher calls `handle.add_torrent(params)` for every
1848        // .torrent file dropped in `settings.watched_folder`. Drop of
1849        // the matching `watched_folder_shutdown_tx` on the actor (or
1850        // explicit send) signals the dispatcher to exit.
1851        let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1852            handle.clone(),
1853            watched_folder_settings_rx,
1854            watched_folder_changed,
1855            watched_folder_shutdown_rx,
1856        );
1857
1858        Ok(handle)
1859    }
1860
1861    /// Add a torrent from parsed .torrent metadata (v1, v2, or hybrid).
1862    ///
1863    /// Low-level entry point; used by the `irontide` facade and internal
1864    /// sim/test code. For user-facing adds, prefer
1865    /// [`add_torrent`](Self::add_torrent) with [`AddTorrentParams`],
1866    /// which resolves categories and `download_dir` via M170 semantics.
1867    ///
1868    /// # Errors
1869    ///
1870    /// Returns an error if the torrent cannot be added or the session is shut down.
1871    pub async fn add_torrent_with_meta(
1872        &self,
1873        meta: irontide_core::TorrentMeta,
1874        storage: Option<Arc<dyn TorrentStorage>>,
1875    ) -> crate::Result<Id20> {
1876        self.add_torrent_with_dir(meta, storage, None).await
1877    }
1878
1879    /// Unified add entry (M170).
1880    ///
1881    /// Resolves the download directory using the precedence documented
1882    /// on [`AddTorrentParams`] and the category registry, then delegates
1883    /// to the magnet or bytes-add path as appropriate. Returns the v1
1884    /// info hash of the new torrent.
1885    ///
1886    /// # Errors
1887    ///
1888    /// - [`Error::CategoryNotFound`](crate::Error::CategoryNotFound) when
1889    ///   `params.category` names a category that is not in the registry.
1890    /// - [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved)
1891    ///   when another task is currently deleting files for the same info
1892    ///   hash (mapped to 409 Conflict by the qBt API).
1893    /// - Propagates parsing errors for bad magnet URIs / .torrent bytes.
1894    /// - Propagates the existing `DuplicateTorrent` / `SessionAtCapacity`
1895    ///   error shapes unchanged.
1896    pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1897        let (tx, rx) = oneshot::channel();
1898        self.cmd_tx
1899            .send(SessionCommand::AddTorrentM170 {
1900                params: Box::new(params),
1901                reply: tx,
1902            })
1903            .await
1904            .map_err(|_| crate::Error::Shutdown)?;
1905        rx.await.map_err(|_| crate::Error::Shutdown)?
1906    }
1907
1908    /// Create a new qBt-compat category (M170).
1909    ///
1910    /// # Errors
1911    ///
1912    /// Returns [`CategoryError`](crate::CategoryError) on name validation
1913    /// failure, duplicate name, or persistence I/O error.
1914    pub async fn create_category(
1915        &self,
1916        name: String,
1917        save_path: PathBuf,
1918    ) -> Result<(), crate::category_manager::CategoryError> {
1919        let (tx, rx) = oneshot::channel();
1920        if self
1921            .cmd_tx
1922            .send(SessionCommand::CreateCategory {
1923                name,
1924                save_path,
1925                reply: tx,
1926            })
1927            .await
1928            .is_err()
1929        {
1930            return Err(crate::category_manager::CategoryError::Persistence(
1931                std::io::Error::other("session shutting down"),
1932            ));
1933        }
1934        rx.await.unwrap_or_else(|_| {
1935            Err(crate::category_manager::CategoryError::Persistence(
1936                std::io::Error::other("session shutting down"),
1937            ))
1938        })
1939    }
1940
1941    /// Update the `save_path` on an existing category (M170).
1942    ///
1943    /// # Errors
1944    ///
1945    /// Returns [`CategoryError::NotFound`](crate::CategoryError::NotFound)
1946    /// when the category does not exist, plus the same persistence /
1947    /// validation error shapes as [`create_category`](Self::create_category).
1948    pub async fn edit_category(
1949        &self,
1950        name: String,
1951        save_path: PathBuf,
1952    ) -> Result<(), crate::category_manager::CategoryError> {
1953        let (tx, rx) = oneshot::channel();
1954        if self
1955            .cmd_tx
1956            .send(SessionCommand::EditCategory {
1957                name,
1958                save_path,
1959                reply: tx,
1960            })
1961            .await
1962            .is_err()
1963        {
1964            return Err(crate::category_manager::CategoryError::Persistence(
1965                std::io::Error::other("session shutting down"),
1966            ));
1967        }
1968        rx.await.unwrap_or_else(|_| {
1969            Err(crate::category_manager::CategoryError::Persistence(
1970                std::io::Error::other("session shutting down"),
1971            ))
1972        })
1973    }
1974
1975    /// Remove zero or more categories (M170). Unknown names are tolerated
1976    /// (qBt behaviour). Returns the names that were actually removed.
1977    /// After removal, any torrent assigned to a removed category has its
1978    /// `category` label cleared and its resume data marked dirty.
1979    pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1980        let (tx, rx) = oneshot::channel();
1981        if self
1982            .cmd_tx
1983            .send(SessionCommand::RemoveCategories { names, reply: tx })
1984            .await
1985            .is_err()
1986        {
1987            return Vec::new();
1988        }
1989        rx.await.unwrap_or_default()
1990    }
1991
1992    /// Snapshot the current category list (M170).
1993    pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1994        let (tx, rx) = oneshot::channel();
1995        if self
1996            .cmd_tx
1997            .send(SessionCommand::ListCategories { reply: tx })
1998            .await
1999            .is_err()
2000        {
2001            return Vec::new();
2002        }
2003        rx.await.unwrap_or_default()
2004    }
2005
2006    /// List every tag name currently in the registry (M171). Sorted.
2007    pub async fn list_tags(&self) -> Vec<String> {
2008        let (tx, rx) = oneshot::channel();
2009        if self
2010            .cmd_tx
2011            .send(SessionCommand::ListTags { reply: tx })
2012            .await
2013            .is_err()
2014        {
2015            return Vec::new();
2016        }
2017        rx.await.unwrap_or_default()
2018    }
2019
2020    /// Create a batch of tags (M171). Returns one
2021    /// `Result<(), TagError>` per requested name so the caller can
2022    /// distinguish which names already existed and which were newly
2023    /// created. Persistence is best-effort on success; any partial
2024    /// persistence failure warns but does not change the per-call reply.
2025    pub async fn create_tags(
2026        &self,
2027        names: Vec<String>,
2028    ) -> Vec<Result<(), crate::tag_manager::TagError>> {
2029        let (tx, rx) = oneshot::channel();
2030        if self
2031            .cmd_tx
2032            .send(SessionCommand::CreateTags { names, reply: tx })
2033            .await
2034            .is_err()
2035        {
2036            return Vec::new();
2037        }
2038        rx.await.unwrap_or_default()
2039    }
2040
2041    /// Delete a batch of tags (M171). Returns the subset of names that
2042    /// were actually present at call time (unknown names are silently
2043    /// skipped, matching qBt's idempotent `deleteTags`).
2044    pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
2045        let (tx, rx) = oneshot::channel();
2046        if self
2047            .cmd_tx
2048            .send(SessionCommand::DeleteTags { names, reply: tx })
2049            .await
2050            .is_err()
2051        {
2052            return Vec::new();
2053        }
2054        rx.await.unwrap_or_default()
2055    }
2056
2057    /// Add the given tags to each torrent in `hashes` (M171).
2058    /// Idempotent — tags that a torrent already has are left alone.
2059    /// Unknown info hashes are silently skipped.
2060    ///
2061    /// # Errors
2062    ///
2063    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
2064    /// session's command channel has closed.
2065    pub async fn add_tags_to_torrents(
2066        &self,
2067        hashes: Vec<Id20>,
2068        tags: Vec<String>,
2069    ) -> crate::Result<()> {
2070        let (tx, rx) = oneshot::channel();
2071        self.cmd_tx
2072            .send(SessionCommand::AddTagsToTorrents {
2073                info_hashes: hashes,
2074                tags,
2075                reply: tx,
2076            })
2077            .await
2078            .map_err(|_| crate::Error::Shutdown)?;
2079        rx.await.map_err(|_| crate::Error::Shutdown)?
2080    }
2081
2082    /// Remove the given tags from each torrent in `hashes` (M171).
2083    /// Unknown info hashes are silently skipped.
2084    ///
2085    /// # Errors
2086    ///
2087    /// Returns [`Error::Shutdown`](crate::Error::Shutdown) if the
2088    /// session's command channel has closed.
2089    pub async fn remove_tags_from_torrents(
2090        &self,
2091        hashes: Vec<Id20>,
2092        tags: Vec<String>,
2093    ) -> crate::Result<()> {
2094        let (tx, rx) = oneshot::channel();
2095        self.cmd_tx
2096            .send(SessionCommand::RemoveTagsFromTorrents {
2097                info_hashes: hashes,
2098                tags,
2099                reply: tx,
2100            })
2101            .await
2102            .map_err(|_| crate::Error::Shutdown)?;
2103        rx.await.map_err(|_| crate::Error::Shutdown)?
2104    }
2105
2106    /// Remove a torrent and delete its on-disk files (M170,
2107    /// `deleteFiles=true`).
2108    ///
2109    /// Pauses the torrent, closes storage handles, then dispatches a
2110    /// `spawn_blocking` file-removal walk via
2111    /// [`delete_torrent_files_sync`](irontide_storage::delete_torrent_files_sync).
2112    /// The info hash is placed in a deletion grace set for the duration of
2113    /// the walk; concurrent calls to [`add_torrent`](Self::add_torrent)
2114    /// with the same hash return [`Error::TorrentBeingRemoved`](crate::Error::TorrentBeingRemoved).
2115    ///
2116    /// # Errors
2117    ///
2118    /// Returns [`Error::TorrentNotFound`](crate::Error::TorrentNotFound)
2119    /// if the info hash is not in the session. I/O failures during file
2120    /// removal are logged and swallowed — always returns `Ok(())` when
2121    /// the torrent is found.
2122    pub async fn remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
2123        let (tx, rx) = oneshot::channel();
2124        self.cmd_tx
2125            .send(SessionCommand::RemoveTorrentWithFiles {
2126                info_hash,
2127                reply: tx,
2128            })
2129            .await
2130            .map_err(|_| crate::Error::Shutdown)?;
2131        rx.await.map_err(|_| crate::Error::Shutdown)?
2132    }
2133
2134    /// Add a torrent with an optional per-torrent download directory override.
2135    ///
2136    /// # Errors
2137    ///
2138    /// Returns an error if the torrent cannot be added or the session is shut down.
2139    pub async fn add_torrent_with_dir(
2140        &self,
2141        meta: irontide_core::TorrentMeta,
2142        storage: Option<Arc<dyn TorrentStorage>>,
2143        download_dir: Option<PathBuf>,
2144    ) -> crate::Result<Id20> {
2145        let (tx, rx) = oneshot::channel();
2146        self.cmd_tx
2147            .send(SessionCommand::AddTorrent {
2148                meta: Box::new(meta),
2149                storage,
2150                download_dir,
2151                reply: tx,
2152            })
2153            .await
2154            .map_err(|_| crate::Error::Shutdown)?;
2155        rx.await.map_err(|_| crate::Error::Shutdown)?
2156    }
2157
2158    /// Add a torrent from a magnet link (metadata fetched via BEP 9).
2159    ///
2160    /// # Errors
2161    ///
2162    /// Returns an error if the torrent cannot be added or the session is shut down.
2163    pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2164        self.add_magnet_with_dir(magnet, None).await
2165    }
2166
2167    /// Add a magnet link with an optional per-torrent download directory override.
2168    ///
2169    /// # Errors
2170    ///
2171    /// Returns an error if the torrent cannot be added or the session is shut down.
2172    pub async fn add_magnet_with_dir(
2173        &self,
2174        magnet: Magnet,
2175        download_dir: Option<PathBuf>,
2176    ) -> crate::Result<Id20> {
2177        let (tx, rx) = oneshot::channel();
2178        self.cmd_tx
2179            .send(SessionCommand::AddMagnet {
2180                magnet,
2181                download_dir,
2182                reply: tx,
2183            })
2184            .await
2185            .map_err(|_| crate::Error::Shutdown)?;
2186        rx.await.map_err(|_| crate::Error::Shutdown)?
2187    }
2188
2189    /// Remove a torrent from the session.
2190    ///
2191    /// # Errors
2192    ///
2193    /// Returns an error if the torrent is not found or the session is shut down.
2194    pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2195        let (tx, rx) = oneshot::channel();
2196        self.cmd_tx
2197            .send(SessionCommand::RemoveTorrent {
2198                info_hash,
2199                reply: tx,
2200            })
2201            .await
2202            .map_err(|_| crate::Error::Shutdown)?;
2203        rx.await.map_err(|_| crate::Error::Shutdown)?
2204    }
2205
2206    /// Pause a torrent.
2207    ///
2208    /// # Errors
2209    ///
2210    /// Returns an error if the session is shut down.
2211    pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2212        let (tx, rx) = oneshot::channel();
2213        self.cmd_tx
2214            .send(SessionCommand::PauseTorrent {
2215                info_hash,
2216                reply: tx,
2217            })
2218            .await
2219            .map_err(|_| crate::Error::Shutdown)?;
2220        rx.await.map_err(|_| crate::Error::Shutdown)?
2221    }
2222
2223    /// Resume a paused torrent.
2224    ///
2225    /// # Errors
2226    ///
2227    /// Returns an error if the session is shut down.
2228    pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2229        let (tx, rx) = oneshot::channel();
2230        self.cmd_tx
2231            .send(SessionCommand::ResumeTorrent {
2232                info_hash,
2233                reply: tx,
2234            })
2235            .await
2236            .map_err(|_| crate::Error::Shutdown)?;
2237        rx.await.map_err(|_| crate::Error::Shutdown)?
2238    }
2239
2240    /// Force-resume a torrent, bypassing queue limits.
2241    ///
2242    /// # Errors
2243    ///
2244    /// Returns an error if the session is shut down.
2245    pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2246        let (tx, rx) = oneshot::channel();
2247        self.cmd_tx
2248            .send(SessionCommand::ForceResumeTorrent {
2249                info_hash,
2250                reply: tx,
2251            })
2252            .await
2253            .map_err(|_| crate::Error::Shutdown)?;
2254        rx.await.map_err(|_| crate::Error::Shutdown)?
2255    }
2256
2257    /// Set a per-torrent seed ratio limit override (`None` = use session default).
2258    ///
2259    /// # Errors
2260    ///
2261    /// Returns an error if the session is shut down.
2262    pub async fn set_torrent_seed_ratio(
2263        &self,
2264        info_hash: Id20,
2265        limit: Option<f64>,
2266    ) -> crate::Result<()> {
2267        let (tx, rx) = oneshot::channel();
2268        self.cmd_tx
2269            .send(SessionCommand::SetTorrentSeedRatio {
2270                info_hash,
2271                limit,
2272                reply: tx,
2273            })
2274            .await
2275            .map_err(|_| crate::Error::Shutdown)?;
2276        rx.await.map_err(|_| crate::Error::Shutdown)?
2277    }
2278
2279    /// Get statistics for a specific torrent.
2280    ///
2281    /// # Errors
2282    ///
2283    /// Returns an error if the session is shut down.
2284    pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2285        let (tx, rx) = oneshot::channel();
2286        self.cmd_tx
2287            .send(SessionCommand::TorrentStats {
2288                info_hash,
2289                reply: tx,
2290            })
2291            .await
2292            .map_err(|_| crate::Error::Shutdown)?;
2293        rx.await.map_err(|_| crate::Error::Shutdown)?
2294    }
2295
2296    /// Snapshot per-peer cumulative unchoke duration for the given torrent.
2297    ///
2298    /// Returns `Ok(Some(map))` when the torrent exists. Each entry is the
2299    /// total time we (this session) had that peer unchoked over the
2300    /// torrent's lifetime, including reconnects (durations are flushed
2301    /// into a per-(SocketAddr × torrent) map on disconnect and re-merged
2302    /// here at query time).
2303    ///
2304    /// Returns `Ok(None)` when the torrent is unknown to this session —
2305    /// the explicit contract distinguishes "torrent missing" from "exists
2306    /// but no peers were ever unchoked".
2307    ///
2308    /// # Errors
2309    ///
2310    /// Returns an error if the session is shut down.
2311    pub async fn peer_unchoke_durations(
2312        &self,
2313        info_hash: Id20,
2314    ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2315        let (tx, rx) = oneshot::channel();
2316        self.cmd_tx
2317            .send(SessionCommand::QueryUnchokeDurations {
2318                info_hash,
2319                reply: tx,
2320            })
2321            .await
2322            .map_err(|_| crate::Error::Shutdown)?;
2323        rx.await.map_err(|_| crate::Error::Shutdown)
2324    }
2325
2326    /// Get metadata info for a specific torrent.
2327    ///
2328    /// # Errors
2329    ///
2330    /// Returns an error if the session is shut down.
2331    pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2332        let (tx, rx) = oneshot::channel();
2333        self.cmd_tx
2334            .send(SessionCommand::TorrentInfo {
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    /// List all active torrent info hashes.
2344    ///
2345    /// # Errors
2346    ///
2347    /// Returns an error if the session is shut down.
2348    pub async fn list_torrents(&self) -> crate::Result<Vec<Id20>> {
2349        let (tx, rx) = oneshot::channel();
2350        self.cmd_tx
2351            .send(SessionCommand::ListTorrents { reply: tx })
2352            .await
2353            .map_err(|_| crate::Error::Shutdown)?;
2354        rx.await.map_err(|_| crate::Error::Shutdown)
2355    }
2356
2357    /// Get aggregate session statistics.
2358    ///
2359    /// # Errors
2360    ///
2361    /// Returns an error if the session is shut down.
2362    pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2363        let (tx, rx) = oneshot::channel();
2364        self.cmd_tx
2365            .send(SessionCommand::SessionStats { reply: tx })
2366            .await
2367            .map_err(|_| crate::Error::Shutdown)?;
2368        rx.await.map_err(|_| crate::Error::Shutdown)
2369    }
2370
2371    /// Collect per-torrent and per-peer debug state for diagnosing dispatch
2372    /// throughput regressions (M187).
2373    ///
2374    /// Individual torrents that do not respond within 500 ms are skipped so
2375    /// the endpoint always returns partial results rather than failing.
2376    ///
2377    /// # Errors
2378    ///
2379    /// Returns an error if the session is shut down.
2380    pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2381        let (tx, rx) = oneshot::channel();
2382        self.cmd_tx
2383            .send(SessionCommand::DebugState { reply: tx })
2384            .await
2385            .map_err(|_| crate::Error::Shutdown)?;
2386        // Use a generous 5 s overall timeout — the per-torrent timeouts are
2387        // 500 ms inside the actor, but with many torrents the total can add up.
2388        tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2389            .await
2390            .map_err(|_| crate::Error::Shutdown)?
2391            .map_err(|_| crate::Error::Shutdown)
2392    }
2393
2394    /// Subscribe to all alerts passing the session-level mask.
2395    #[must_use]
2396    pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2397        self.alert_tx.subscribe()
2398    }
2399
2400    /// Subscribe with per-subscriber category filtering.
2401    #[must_use]
2402    pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2403        AlertStream::new(self.alert_tx.subscribe(), filter)
2404    }
2405
2406    /// Trigger an immediate session stats snapshot and alert.
2407    ///
2408    /// # Errors
2409    ///
2410    /// Returns an error if the session is shut down.
2411    pub async fn post_session_stats(&self) -> crate::Result<()> {
2412        self.cmd_tx
2413            .send(SessionCommand::PostSessionStats)
2414            .await
2415            .map_err(|_| crate::Error::Shutdown)
2416    }
2417
2418    /// Access the shared atomic counters (read-only handle).
2419    #[must_use]
2420    pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2421        &self.counters
2422    }
2423
2424    /// Atomically update the session-level alert mask.
2425    pub fn set_alert_mask(&self, mask: AlertCategory) {
2426        self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2427    }
2428
2429    /// Read the current session-level alert mask.
2430    #[must_use]
2431    pub fn alert_mask(&self) -> AlertCategory {
2432        AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2433    }
2434
2435    /// Add peers to a specific torrent by info hash.
2436    ///
2437    /// # Errors
2438    ///
2439    /// Returns an error if the session is shut down.
2440    pub async fn add_peers(
2441        &self,
2442        info_hash: Id20,
2443        peers: Vec<SocketAddr>,
2444        source: crate::peer_state::PeerSource,
2445    ) -> crate::Result<()> {
2446        let (tx, rx) = oneshot::channel();
2447        self.cmd_tx
2448            .send(SessionCommand::AddPeers {
2449                info_hash,
2450                peers,
2451                source,
2452                reply: tx,
2453            })
2454            .await
2455            .map_err(|_| crate::Error::Shutdown)?;
2456        rx.await.map_err(|_| crate::Error::Shutdown)?
2457    }
2458
2459    /// Gracefully shut down the session and all torrents.
2460    ///
2461    /// # Errors
2462    ///
2463    /// Returns an error if the session is shut down.
2464    pub async fn shutdown(&self) -> crate::Result<()> {
2465        // Timeout prevents hang if SessionActor is processing a heavy batch
2466        let _ = tokio::time::timeout(
2467            std::time::Duration::from_secs(10),
2468            self.cmd_tx.send(SessionCommand::Shutdown),
2469        )
2470        .await;
2471        Ok(())
2472    }
2473
2474    /// Save resume data for a specific torrent.
2475    ///
2476    /// # Errors
2477    ///
2478    /// Returns an error if the I/O operation fails.
2479    pub async fn save_torrent_resume_data(
2480        &self,
2481        info_hash: Id20,
2482    ) -> crate::Result<irontide_core::FastResumeData> {
2483        let (tx, rx) = oneshot::channel();
2484        self.cmd_tx
2485            .send(SessionCommand::SaveTorrentResumeData {
2486                info_hash,
2487                reply: tx,
2488            })
2489            .await
2490            .map_err(|_| crate::Error::Shutdown)?;
2491        rx.await.map_err(|_| crate::Error::Shutdown)?
2492    }
2493
2494    /// Save full session state (all torrent resume data + DHT node cache).
2495    ///
2496    /// # Errors
2497    ///
2498    /// Returns an error if the I/O operation fails.
2499    pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2500        let (tx, rx) = oneshot::channel();
2501        self.cmd_tx
2502            .send(SessionCommand::SaveSessionState { reply: tx })
2503            .await
2504            .map_err(|_| crate::Error::Shutdown)?;
2505        rx.await.map_err(|_| crate::Error::Shutdown)?
2506    }
2507
2508    /// Load and restore torrents from per-torrent resume files on disk.
2509    ///
2510    /// Scans the resume directory for `.resume` files, deserializes each one,
2511    /// reconstructs the torrent metadata, and re-adds it to the session. For
2512    /// resolved torrents (with stored info dict), the piece bitmap is restored.
2513    /// Unresolved magnets are re-added as magnet links.
2514    ///
2515    /// # Errors
2516    ///
2517    /// Returns [`crate::Error::Shutdown`] if the session has been shut down.
2518    pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2519        let (tx, rx) = oneshot::channel();
2520        self.cmd_tx
2521            .send(SessionCommand::LoadResumeState { reply: tx })
2522            .await
2523            .map_err(|_| crate::Error::Shutdown)?;
2524        rx.await.map_err(|_| crate::Error::Shutdown)?
2525    }
2526
2527    /// Save per-torrent resume files for all dirty torrents.
2528    ///
2529    /// Iterates every torrent in the session, checks the `need_save_resume`
2530    /// dirty flag, serializes resume data to disk, and clears the flag.
2531    /// Returns the number of files written.
2532    ///
2533    /// # Errors
2534    ///
2535    /// Returns [`Error::Shutdown`] if the session actor has stopped.
2536    pub async fn save_resume_state(&self) -> crate::Result<usize> {
2537        let (tx, rx) = oneshot::channel();
2538        self.cmd_tx
2539            .send(SessionCommand::SaveResumeState { reply: tx })
2540            .await
2541            .map_err(|_| crate::Error::Shutdown)?;
2542        rx.await.map_err(|_| crate::Error::Shutdown)?
2543    }
2544
2545    /// Get the queue position of a torrent. Returns -1 if not auto-managed.
2546    ///
2547    /// # Errors
2548    ///
2549    /// Returns an error if the session is shut down.
2550    pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2551        let (tx, rx) = oneshot::channel();
2552        self.cmd_tx
2553            .send(SessionCommand::QueuePosition {
2554                info_hash,
2555                reply: tx,
2556            })
2557            .await
2558            .map_err(|_| crate::Error::Shutdown)?;
2559        rx.await.map_err(|_| crate::Error::Shutdown)?
2560    }
2561
2562    /// Set the absolute queue position of a torrent. Shifts other torrents.
2563    ///
2564    /// # Errors
2565    ///
2566    /// Returns an error if the session is shut down.
2567    pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2568        let (tx, rx) = oneshot::channel();
2569        self.cmd_tx
2570            .send(SessionCommand::SetQueuePosition {
2571                info_hash,
2572                pos,
2573                reply: tx,
2574            })
2575            .await
2576            .map_err(|_| crate::Error::Shutdown)?;
2577        rx.await.map_err(|_| crate::Error::Shutdown)?
2578    }
2579
2580    /// M254: include or exclude a torrent from session queue
2581    /// auto-management. Excluded torrents keep their pause/resume state
2582    /// under manual control; the queue renumbers around them.
2583    ///
2584    /// # Errors
2585    ///
2586    /// Returns an error if the torrent is not found or the session is shut down.
2587    pub async fn set_auto_managed(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
2588        let (tx, rx) = oneshot::channel();
2589        self.cmd_tx
2590            .send(SessionCommand::SetAutoManaged {
2591                info_hash,
2592                enabled,
2593                reply: tx,
2594            })
2595            .await
2596            .map_err(|_| crate::Error::Shutdown)?;
2597        rx.await.map_err(|_| crate::Error::Shutdown)?
2598    }
2599
2600    /// Move a torrent one position up (lower number = higher priority).
2601    ///
2602    /// # Errors
2603    ///
2604    /// Returns an error if the session is shut down.
2605    pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2606        let (tx, rx) = oneshot::channel();
2607        self.cmd_tx
2608            .send(SessionCommand::QueuePositionUp {
2609                info_hash,
2610                reply: tx,
2611            })
2612            .await
2613            .map_err(|_| crate::Error::Shutdown)?;
2614        rx.await.map_err(|_| crate::Error::Shutdown)?
2615    }
2616
2617    /// Move a torrent one position down.
2618    ///
2619    /// # Errors
2620    ///
2621    /// Returns an error if the session is shut down.
2622    pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2623        let (tx, rx) = oneshot::channel();
2624        self.cmd_tx
2625            .send(SessionCommand::QueuePositionDown {
2626                info_hash,
2627                reply: tx,
2628            })
2629            .await
2630            .map_err(|_| crate::Error::Shutdown)?;
2631        rx.await.map_err(|_| crate::Error::Shutdown)?
2632    }
2633
2634    /// Move a torrent to position 0 (highest priority).
2635    ///
2636    /// # Errors
2637    ///
2638    /// Returns an error if the session is shut down.
2639    pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2640        let (tx, rx) = oneshot::channel();
2641        self.cmd_tx
2642            .send(SessionCommand::QueuePositionTop {
2643                info_hash,
2644                reply: tx,
2645            })
2646            .await
2647            .map_err(|_| crate::Error::Shutdown)?;
2648        rx.await.map_err(|_| crate::Error::Shutdown)?
2649    }
2650
2651    /// Move a torrent to the last position (lowest priority).
2652    ///
2653    /// # Errors
2654    ///
2655    /// Returns an error if the session is shut down.
2656    pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2657        let (tx, rx) = oneshot::channel();
2658        self.cmd_tx
2659            .send(SessionCommand::QueuePositionBottom {
2660                info_hash,
2661                reply: tx,
2662            })
2663            .await
2664            .map_err(|_| crate::Error::Shutdown)?;
2665        rx.await.map_err(|_| crate::Error::Shutdown)?
2666    }
2667
2668    /// Ban a peer IP session-wide. All torrents will disconnect and refuse this IP.
2669    ///
2670    /// # Errors
2671    ///
2672    /// Returns an error if the session is shut down.
2673    pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2674        let (tx, rx) = oneshot::channel();
2675        self.cmd_tx
2676            .send(SessionCommand::BanPeer { ip, reply: tx })
2677            .await
2678            .map_err(|_| crate::Error::Shutdown)?;
2679        rx.await.map_err(|_| crate::Error::Shutdown)
2680    }
2681
2682    /// Remove a ban and clear strikes for an IP. Returns `true` if the IP was banned.
2683    ///
2684    /// # Errors
2685    ///
2686    /// Returns an error if the session is shut down.
2687    pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2688        let (tx, rx) = oneshot::channel();
2689        self.cmd_tx
2690            .send(SessionCommand::UnbanPeer { ip, reply: tx })
2691            .await
2692            .map_err(|_| crate::Error::Shutdown)?;
2693        rx.await.map_err(|_| crate::Error::Shutdown)
2694    }
2695
2696    /// Replace the session-wide IP filter. Connected peers that are now blocked will
2697    /// be refused on subsequent connection attempts.
2698    ///
2699    /// # Errors
2700    ///
2701    /// Returns an error if the session is shut down.
2702    pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2703        let (tx, rx) = oneshot::channel();
2704        self.cmd_tx
2705            .send(SessionCommand::SetIpFilter { filter, reply: tx })
2706            .await
2707            .map_err(|_| crate::Error::Shutdown)?;
2708        rx.await.map_err(|_| crate::Error::Shutdown)
2709    }
2710
2711    /// Get a clone of the current IP filter.
2712    ///
2713    /// # Errors
2714    ///
2715    /// Returns an error if the session is shut down.
2716    pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2717        let (tx, rx) = oneshot::channel();
2718        self.cmd_tx
2719            .send(SessionCommand::GetIpFilter { reply: tx })
2720            .await
2721            .map_err(|_| crate::Error::Shutdown)?;
2722        rx.await.map_err(|_| crate::Error::Shutdown)
2723    }
2724
2725    /// Get a clone of the current session settings.
2726    ///
2727    /// # Errors
2728    ///
2729    /// Returns an error if the session is shut down.
2730    pub async fn settings(&self) -> crate::Result<Settings> {
2731        let (tx, rx) = oneshot::channel();
2732        self.cmd_tx
2733            .send(SessionCommand::GetSettings { reply: tx })
2734            .await
2735            .map_err(|_| crate::Error::Shutdown)?;
2736        rx.await.map_err(|_| crate::Error::Shutdown)
2737    }
2738
2739    /// Apply new settings at runtime.
2740    ///
2741    /// Validates the settings, updates rate limiters immediately, and stores
2742    /// the new settings. Sub-actor reconfiguration (disk, DHT, NAT) takes
2743    /// effect on next session restart for the fields that remain in
2744    /// `restart_required`; `listen_port`, `dht`, `lsd` are now applied
2745    /// live (M173 Lane B B10).
2746    ///
2747    /// # Errors
2748    ///
2749    /// Returns [`crate::Error::ConcurrentReconfig`] if another
2750    /// `apply_settings` / `apply_settings_classified` call is still in
2751    /// flight (M173 Lane B B11). Caller should retry shortly.
2752    pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2753        let _guard = self
2754            .reconfig_in_flight
2755            .try_lock()
2756            .ok_or(crate::Error::ConcurrentReconfig)?;
2757        let (tx, rx) = oneshot::channel();
2758        self.cmd_tx
2759            .send(SessionCommand::ApplySettings {
2760                settings: Box::new(settings),
2761                reply: tx,
2762            })
2763            .await
2764            .map_err(|_| crate::Error::Shutdown)?;
2765        rx.await.map_err(|_| crate::Error::Shutdown)?
2766    }
2767
2768    /// Apply new settings and return a classification of which fields took
2769    /// effect immediately versus which require a session restart (M171 D3.5).
2770    ///
2771    /// Identical behaviour to [`apply_settings`](Self::apply_settings), but
2772    /// the return value lets callers surface the "restart to apply" UX —
2773    /// specifically the `X-IronTide-Restart-Pending` response header on the
2774    /// qBt v2 `setPreferences` endpoint and the GUI restart-banner.
2775    ///
2776    /// M173 Lane B (B11): the in-flight guard is acquired BEFORE the
2777    /// snapshot read, so concurrent callers cannot race the
2778    /// read-modify-write. The second caller hits
2779    /// [`crate::Error::ConcurrentReconfig`].
2780    ///
2781    /// # Errors
2782    ///
2783    /// Returns [`crate::Error::ConcurrentReconfig`] if another reconfig
2784    /// is still in flight. Returns [`crate::Error::InvalidSettings`] if
2785    /// the patch fails validation. Returns [`crate::Error::Shutdown`]
2786    /// if the session has shut down.
2787    pub async fn apply_settings_classified(
2788        &self,
2789        settings: Settings,
2790    ) -> crate::Result<AppliedSettings> {
2791        let _guard = self
2792            .reconfig_in_flight
2793            .try_lock()
2794            .ok_or(crate::Error::ConcurrentReconfig)?;
2795        // Snapshot AFTER acquiring the guard so the classification is
2796        // computed against a stable pre-call state.
2797        let snapshot = self.settings().await?;
2798        let immediate = classify_immediate(&snapshot, &settings);
2799        let restart_required = classify_restart_required(&snapshot, &settings);
2800        // Inner apply_settings tries to re-acquire the guard. To avoid
2801        // a self-deadlock, we send the command directly here rather
2802        // than calling self.apply_settings(), which would error out
2803        // with ConcurrentReconfig because we already hold the lock.
2804        let (tx, rx) = oneshot::channel();
2805        self.cmd_tx
2806            .send(SessionCommand::ApplySettings {
2807                settings: Box::new(settings),
2808                reply: tx,
2809            })
2810            .await
2811            .map_err(|_| crate::Error::Shutdown)?;
2812        rx.await.map_err(|_| crate::Error::Shutdown)??;
2813        Ok(AppliedSettings {
2814            immediate,
2815            restart_required,
2816        })
2817    }
2818
2819    /// Get the current DHT routing-table size, summed across IPv4 and IPv6
2820    /// instances (M171 D4).
2821    ///
2822    /// Returns `Ok(0)` when DHT is disabled for both address families, or
2823    /// when neither instance has bootstrapped yet. Wired into the qBt v2
2824    /// `transferInfo.dht_nodes` field and the DHT pseudo-tracker's
2825    /// `num_peers` column on `/api/v2/torrents/trackers`.
2826    ///
2827    /// # Errors
2828    ///
2829    /// Returns an error if the session is shut down.
2830    pub async fn dht_node_count(&self) -> crate::Result<usize> {
2831        let (tx, rx) = oneshot::channel();
2832        self.cmd_tx
2833            .send(SessionCommand::DhtNodeCount { reply: tx })
2834            .await
2835            .map_err(|_| crate::Error::Shutdown)?;
2836        rx.await.map_err(|_| crate::Error::Shutdown)
2837    }
2838
2839    /// Get the list of currently banned peer IPs.
2840    ///
2841    /// # Errors
2842    ///
2843    /// Returns an error if the session is shut down.
2844    pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2845        let (tx, rx) = oneshot::channel();
2846        self.cmd_tx
2847            .send(SessionCommand::BannedPeers { reply: tx })
2848            .await
2849            .map_err(|_| crate::Error::Shutdown)?;
2850        rx.await.map_err(|_| crate::Error::Shutdown)
2851    }
2852
2853    /// Move a torrent's data files to a new download directory.
2854    ///
2855    /// # Errors
2856    ///
2857    /// Returns an error if the session is shut down.
2858    pub async fn move_torrent_storage(
2859        &self,
2860        info_hash: Id20,
2861        new_path: std::path::PathBuf,
2862    ) -> crate::Result<()> {
2863        let (tx, rx) = oneshot::channel();
2864        self.cmd_tx
2865            .send(SessionCommand::MoveTorrentStorage {
2866                info_hash,
2867                new_path,
2868                reply: tx,
2869            })
2870            .await
2871            .map_err(|_| crate::Error::Shutdown)?;
2872        rx.await.map_err(|_| crate::Error::Shutdown)?
2873    }
2874
2875    /// Opens a file stream for sequential reading (`AsyncRead` + `AsyncSeek`).
2876    ///
2877    /// The returned [`FileStream`](crate::streaming::FileStream) reads data from
2878    /// a specific file within a torrent, blocking on pieces that haven't been
2879    /// downloaded yet.
2880    ///
2881    /// # Errors
2882    ///
2883    /// Returns an error if the session is shut down.
2884    pub async fn open_file(
2885        &self,
2886        info_hash: Id20,
2887        file_index: usize,
2888    ) -> crate::Result<crate::streaming::FileStream> {
2889        let (tx, rx) = oneshot::channel();
2890        self.cmd_tx
2891            .send(SessionCommand::OpenFile {
2892                info_hash,
2893                file_index,
2894                reply: tx,
2895            })
2896            .await
2897            .map_err(|_| crate::Error::Shutdown)?;
2898        rx.await.map_err(|_| crate::Error::Shutdown)?
2899    }
2900
2901    /// Force all trackers for a torrent to re-announce immediately.
2902    ///
2903    /// # Errors
2904    ///
2905    /// Returns an error if the session is shut down.
2906    pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2907        let (tx, rx) = oneshot::channel();
2908        self.cmd_tx
2909            .send(SessionCommand::ForceReannounce {
2910                info_hash,
2911                reply: tx,
2912            })
2913            .await
2914            .map_err(|_| crate::Error::Shutdown)?;
2915        rx.await.map_err(|_| crate::Error::Shutdown)?
2916    }
2917
2918    /// Get the list of all configured trackers with their status for a torrent.
2919    ///
2920    /// # Errors
2921    ///
2922    /// Returns an error if the session is shut down.
2923    pub async fn tracker_list(
2924        &self,
2925        info_hash: Id20,
2926    ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2927        let (tx, rx) = oneshot::channel();
2928        self.cmd_tx
2929            .send(SessionCommand::TrackerList {
2930                info_hash,
2931                reply: tx,
2932            })
2933            .await
2934            .map_err(|_| crate::Error::Shutdown)?;
2935        rx.await.map_err(|_| crate::Error::Shutdown)?
2936    }
2937
2938    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2939    /// PEX (BEP 11) for this torrent since the actor started.
2940    ///
2941    /// # Errors
2942    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2943    ///   the hash is unknown.
2944    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2945    pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2946        let counts = self.peer_source_counts(info_hash).await?;
2947        Ok(counts.0)
2948    }
2949
2950    /// M178 Lane B3 / TODO-2: cumulative count of UNIQUE peers received via
2951    /// LSD (BEP 14) multicast for this torrent since the actor started.
2952    ///
2953    /// # Errors
2954    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2955    ///   the hash is unknown.
2956    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2957    pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2958        let counts = self.peer_source_counts(info_hash).await?;
2959        Ok(counts.1)
2960    }
2961
2962    async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2963        let (tx, rx) = oneshot::channel();
2964        self.cmd_tx
2965            .send(SessionCommand::GetPeerSourceCounts {
2966                info_hash,
2967                reply: tx,
2968            })
2969            .await
2970            .map_err(|_| crate::Error::Shutdown)?;
2971        rx.await.map_err(|_| crate::Error::Shutdown)?
2972    }
2973
2974    /// M178 Lane C: per-URL web-seed stats snapshot for the qBt v2
2975    /// webseeds endpoint and the GUI HTTP Sources tab.
2976    ///
2977    /// # Errors
2978    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) when
2979    ///   the hash is unknown.
2980    /// - [`Error::Shutdown`](crate::Error::Shutdown) on actor shutdown.
2981    pub async fn web_seed_stats(
2982        &self,
2983        info_hash: Id20,
2984    ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2985        let (tx, rx) = oneshot::channel();
2986        self.cmd_tx
2987            .send(SessionCommand::GetWebSeedStats {
2988                info_hash,
2989                reply: tx,
2990            })
2991            .await
2992            .map_err(|_| crate::Error::Shutdown)?;
2993        rx.await.map_err(|_| crate::Error::Shutdown)?
2994    }
2995
2996    /// M171 Lane B: list the web seed URLs (BEP 19 + BEP 17 merged) for a torrent.
2997    ///
2998    /// BEP 19 `url-list` URLs come first, followed by BEP 17 `httpseeds`
2999    /// URLs — the wire order. Returns an empty vec when the torrent's
3000    /// metadata has not yet been fetched (magnet still resolving the
3001    /// info dictionary).
3002    ///
3003    /// # Errors
3004    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
3005    ///   info-hash is unknown.
3006    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
3007    ///   command channel has closed.
3008    pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
3009        let (tx, rx) = oneshot::channel();
3010        self.cmd_tx
3011            .send(SessionCommand::GetWebSeeds {
3012                info_hash,
3013                reply: tx,
3014            })
3015            .await
3016            .map_err(|_| crate::Error::Shutdown)?;
3017        rx.await.map_err(|_| crate::Error::Shutdown)?
3018    }
3019
3020    /// M171 Lane B: snapshot the per-piece qBt state codes for a torrent.
3021    ///
3022    /// Returns a `Vec<u8>` one byte per piece — `0` = not downloaded,
3023    /// `1` = downloading, `2` = downloaded + checked. An empty vec is
3024    /// returned when metadata hasn't resolved yet (magnet still fetching
3025    /// the info dictionary) — callers should map that to a 404.
3026    ///
3027    /// # Errors
3028    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
3029    ///   info-hash is unknown.
3030    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
3031    ///   command channel has closed.
3032    pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
3033        let (tx, rx) = oneshot::channel();
3034        self.cmd_tx
3035            .send(SessionCommand::GetPieceStates {
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    /// M171 Lane B: paginated piece hash list for a torrent.
3045    ///
3046    /// v1 / hybrid torrents return SHA-1 hashes (40-char hex strings);
3047    /// v2-only torrents return SHA-256 hashes (64-char hex strings).
3048    /// `offset` and `limit` are clamped to the real hash count inside
3049    /// the actor — callers can pass arbitrary values without overflow
3050    /// concerns. An empty vec is returned when metadata hasn't resolved
3051    /// yet — callers should map that to a 404.
3052    ///
3053    /// # Errors
3054    /// - [`Error::TorrentNotFound`](crate::Error::TorrentNotFound) if the
3055    ///   info-hash is unknown.
3056    /// - [`Error::Shutdown`](crate::Error::Shutdown) if the session's
3057    ///   command channel has closed.
3058    pub async fn get_piece_hashes(
3059        &self,
3060        info_hash: Id20,
3061        offset: u32,
3062        limit: u32,
3063    ) -> crate::Result<Vec<String>> {
3064        let (tx, rx) = oneshot::channel();
3065        self.cmd_tx
3066            .send(SessionCommand::GetPieceHashes {
3067                info_hash,
3068                offset,
3069                limit,
3070                reply: tx,
3071            })
3072            .await
3073            .map_err(|_| crate::Error::Shutdown)?;
3074        rx.await.map_err(|_| crate::Error::Shutdown)?
3075    }
3076
3077    /// Scrape trackers for seeder/leecher counts for a torrent.
3078    ///
3079    /// # Errors
3080    ///
3081    /// Returns an error if the session is shut down.
3082    pub async fn scrape(
3083        &self,
3084        info_hash: Id20,
3085    ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
3086        let (tx, rx) = oneshot::channel();
3087        self.cmd_tx
3088            .send(SessionCommand::Scrape {
3089                info_hash,
3090                reply: tx,
3091            })
3092            .await
3093            .map_err(|_| crate::Error::Shutdown)?;
3094        rx.await.map_err(|_| crate::Error::Shutdown)?
3095    }
3096
3097    /// Set the download priority of a specific file within a torrent.
3098    ///
3099    /// # Errors
3100    ///
3101    /// Returns an error if the session is shut down.
3102    pub async fn set_file_priority(
3103        &self,
3104        info_hash: Id20,
3105        index: usize,
3106        priority: irontide_core::FilePriority,
3107    ) -> crate::Result<()> {
3108        let (tx, rx) = oneshot::channel();
3109        self.cmd_tx
3110            .send(SessionCommand::SetFilePriority {
3111                info_hash,
3112                index,
3113                priority,
3114                reply: tx,
3115            })
3116            .await
3117            .map_err(|_| crate::Error::Shutdown)?;
3118        rx.await.map_err(|_| crate::Error::Shutdown)?
3119    }
3120
3121    /// Get the current per-file priorities for a torrent.
3122    ///
3123    /// # Errors
3124    ///
3125    /// Returns an error if the session is shut down.
3126    pub async fn file_priorities(
3127        &self,
3128        info_hash: Id20,
3129    ) -> crate::Result<Vec<irontide_core::FilePriority>> {
3130        let (tx, rx) = oneshot::channel();
3131        self.cmd_tx
3132            .send(SessionCommand::FilePriorities {
3133                info_hash,
3134                reply: tx,
3135            })
3136            .await
3137            .map_err(|_| crate::Error::Shutdown)?;
3138        rx.await.map_err(|_| crate::Error::Shutdown)?
3139    }
3140
3141    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
3142    ///
3143    /// # Errors
3144    ///
3145    /// Returns an error if the data cannot be parsed or I/O fails.
3146    pub async fn set_download_limit(
3147        &self,
3148        info_hash: Id20,
3149        bytes_per_sec: u64,
3150    ) -> crate::Result<()> {
3151        let (tx, rx) = oneshot::channel();
3152        self.cmd_tx
3153            .send(SessionCommand::SetDownloadLimit {
3154                info_hash,
3155                bytes_per_sec,
3156                reply: tx,
3157            })
3158            .await
3159            .map_err(|_| crate::Error::Shutdown)?;
3160        rx.await.map_err(|_| crate::Error::Shutdown)?
3161    }
3162
3163    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
3164    ///
3165    /// # Errors
3166    ///
3167    /// Returns an error if the data cannot be parsed or I/O fails.
3168    pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
3169        let (tx, rx) = oneshot::channel();
3170        self.cmd_tx
3171            .send(SessionCommand::SetUploadLimit {
3172                info_hash,
3173                bytes_per_sec,
3174                reply: tx,
3175            })
3176            .await
3177            .map_err(|_| crate::Error::Shutdown)?;
3178        rx.await.map_err(|_| crate::Error::Shutdown)?
3179    }
3180
3181    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
3182    ///
3183    /// # Errors
3184    ///
3185    /// Returns an error if the data cannot be parsed or I/O fails.
3186    pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3187        let (tx, rx) = oneshot::channel();
3188        self.cmd_tx
3189            .send(SessionCommand::DownloadLimit {
3190                info_hash,
3191                reply: tx,
3192            })
3193            .await
3194            .map_err(|_| crate::Error::Shutdown)?;
3195        rx.await.map_err(|_| crate::Error::Shutdown)?
3196    }
3197
3198    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
3199    ///
3200    /// # Errors
3201    ///
3202    /// Returns an error if the data cannot be parsed or I/O fails.
3203    pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3204        let (tx, rx) = oneshot::channel();
3205        self.cmd_tx
3206            .send(SessionCommand::UploadLimit {
3207                info_hash,
3208                reply: tx,
3209            })
3210            .await
3211            .map_err(|_| crate::Error::Shutdown)?;
3212        rx.await.map_err(|_| crate::Error::Shutdown)?
3213    }
3214
3215    /// Enable or disable sequential (in-order) piece downloading for a torrent.
3216    ///
3217    /// # Errors
3218    ///
3219    /// Returns an error if the data cannot be parsed or I/O fails.
3220    pub async fn set_sequential_download(
3221        &self,
3222        info_hash: Id20,
3223        enabled: bool,
3224    ) -> crate::Result<()> {
3225        let (tx, rx) = oneshot::channel();
3226        self.cmd_tx
3227            .send(SessionCommand::SetSequentialDownload {
3228                info_hash,
3229                enabled,
3230                reply: tx,
3231            })
3232            .await
3233            .map_err(|_| crate::Error::Shutdown)?;
3234        rx.await.map_err(|_| crate::Error::Shutdown)?
3235    }
3236
3237    /// Query whether sequential downloading is enabled for a torrent.
3238    ///
3239    /// # Errors
3240    ///
3241    /// Returns an error if the data cannot be parsed or I/O fails.
3242    pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3243        let (tx, rx) = oneshot::channel();
3244        self.cmd_tx
3245            .send(SessionCommand::IsSequentialDownload {
3246                info_hash,
3247                reply: tx,
3248            })
3249            .await
3250            .map_err(|_| crate::Error::Shutdown)?;
3251        rx.await.map_err(|_| crate::Error::Shutdown)?
3252    }
3253
3254    /// M253/ER2: enable or disable first/last-pieces-first piece ordering
3255    /// for a torrent (every selected file's boundary pieces beat all
3256    /// middles — streaming-friendly), independent of full sequential mode.
3257    ///
3258    /// # Errors
3259    ///
3260    /// Returns an error if the torrent is not found or the session is shut down.
3261    pub async fn set_prioritize_first_last_pieces(
3262        &self,
3263        info_hash: Id20,
3264        enabled: bool,
3265    ) -> crate::Result<()> {
3266        let (tx, rx) = oneshot::channel();
3267        self.cmd_tx
3268            .send(SessionCommand::SetPrioritizeFirstLastPieces {
3269                info_hash,
3270                enabled,
3271                reply: tx,
3272            })
3273            .await
3274            .map_err(|_| crate::Error::Shutdown)?;
3275        rx.await.map_err(|_| crate::Error::Shutdown)?
3276    }
3277
3278    /// M253/ER2: query whether first/last-pieces-first ordering is enabled.
3279    ///
3280    /// # Errors
3281    ///
3282    /// Returns an error if the torrent is not found or the session is shut down.
3283    pub async fn is_prioritize_first_last_pieces(&self, info_hash: Id20) -> crate::Result<bool> {
3284        let (tx, rx) = oneshot::channel();
3285        self.cmd_tx
3286            .send(SessionCommand::IsPrioritizeFirstLastPieces {
3287                info_hash,
3288                reply: tx,
3289            })
3290            .await
3291            .map_err(|_| crate::Error::Shutdown)?;
3292        rx.await.map_err(|_| crate::Error::Shutdown)?
3293    }
3294
3295    /// Enable or disable BEP 16 super seeding mode for a torrent.
3296    ///
3297    /// # Errors
3298    ///
3299    /// Returns an error if the session is shut down.
3300    pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3301        let (tx, rx) = oneshot::channel();
3302        self.cmd_tx
3303            .send(SessionCommand::SetSuperSeeding {
3304                info_hash,
3305                enabled,
3306                reply: tx,
3307            })
3308            .await
3309            .map_err(|_| crate::Error::Shutdown)?;
3310        rx.await.map_err(|_| crate::Error::Shutdown)?
3311    }
3312
3313    /// Query whether BEP 16 super seeding mode is enabled for a torrent.
3314    ///
3315    /// # Errors
3316    ///
3317    /// Returns an error if the session is shut down.
3318    pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3319        let (tx, rx) = oneshot::channel();
3320        self.cmd_tx
3321            .send(SessionCommand::IsSuperSeeding {
3322                info_hash,
3323                reply: tx,
3324            })
3325            .await
3326            .map_err(|_| crate::Error::Shutdown)?;
3327        rx.await.map_err(|_| crate::Error::Shutdown)?
3328    }
3329
3330    /// Enable or disable user-requested seed-only mode for a torrent (M159).
3331    ///
3332    /// When `enabled` is `true`, the engine stops scheduling new block requests
3333    /// and cancels all in-flight requests for the torrent, but continues to
3334    /// serve uploads to interested peers. This is distinct from "naturally
3335    /// seeding" (all pieces downloaded): it represents an explicit user toggle
3336    /// layered on top of the download state.
3337    ///
3338    /// Toggling back to `false` resumes normal piece scheduling.
3339    ///
3340    /// # Errors
3341    ///
3342    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` is not registered
3343    /// in the session, or [`crate::Error::Shutdown`] if the session actor has
3344    /// terminated.
3345    pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3346        let (tx, rx) = oneshot::channel();
3347        self.cmd_tx
3348            .send(SessionCommand::SetSeedMode {
3349                info_hash,
3350                enabled,
3351                reply: tx,
3352            })
3353            .await
3354            .map_err(|_| crate::Error::Shutdown)?;
3355        rx.await.map_err(|_| crate::Error::Shutdown)?
3356    }
3357
3358    /// Add a new tracker URL to a torrent.
3359    ///
3360    /// The URL is validated and deduplicated by the tracker manager.
3361    ///
3362    /// # Errors
3363    ///
3364    /// Returns an error if the session is shut down.
3365    pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3366        let (tx, rx) = oneshot::channel();
3367        self.cmd_tx
3368            .send(SessionCommand::AddTracker {
3369                info_hash,
3370                url,
3371                reply: tx,
3372            })
3373            .await
3374            .map_err(|_| crate::Error::Shutdown)?;
3375        rx.await.map_err(|_| crate::Error::Shutdown)?
3376    }
3377
3378    /// Replace all tracker URLs for a torrent.
3379    ///
3380    /// # Errors
3381    ///
3382    /// Returns an error if the session is shut down.
3383    pub async fn replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3384        let (tx, rx) = oneshot::channel();
3385        self.cmd_tx
3386            .send(SessionCommand::ReplaceTrackers {
3387                info_hash,
3388                urls,
3389                reply: tx,
3390            })
3391            .await
3392            .map_err(|_| crate::Error::Shutdown)?;
3393        rx.await.map_err(|_| crate::Error::Shutdown)?
3394    }
3395
3396    /// Trigger a full piece verification (force recheck) for a torrent.
3397    ///
3398    /// Clears all piece completion data, re-verifies every piece, and
3399    /// transitions to `Seeding` or `Downloading` depending on the result.
3400    /// Returns after the recheck is complete.
3401    ///
3402    /// # Errors
3403    ///
3404    /// Returns an error if the session is shut down.
3405    pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3406        let (tx, rx) = oneshot::channel();
3407        self.cmd_tx
3408            .send(SessionCommand::ForceRecheck {
3409                info_hash,
3410                reply: tx,
3411            })
3412            .await
3413            .map_err(|_| crate::Error::Shutdown)?;
3414        rx.await.map_err(|_| crate::Error::Shutdown)?
3415    }
3416
3417    /// Rename a file within a torrent on disk.
3418    ///
3419    /// Changes the filename of the specified file (by index) to `new_name`.
3420    /// The file stays in the same directory; only the filename component changes.
3421    /// Fires a `FileRenamed` alert on success.
3422    ///
3423    /// # Errors
3424    ///
3425    /// Returns an error if the session is shut down.
3426    pub async fn rename_file(
3427        &self,
3428        info_hash: Id20,
3429        file_index: usize,
3430        new_name: String,
3431    ) -> crate::Result<()> {
3432        let (tx, rx) = oneshot::channel();
3433        self.cmd_tx
3434            .send(SessionCommand::RenameFile {
3435                info_hash,
3436                file_index,
3437                new_name,
3438                reply: tx,
3439            })
3440            .await
3441            .map_err(|_| crate::Error::Shutdown)?;
3442        rx.await.map_err(|_| crate::Error::Shutdown)?
3443    }
3444
3445    /// Set the per-torrent maximum number of connections (0 = use global default).
3446    ///
3447    /// # Errors
3448    ///
3449    /// Returns an error if the connection or binding fails.
3450    pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3451        let (tx, rx) = oneshot::channel();
3452        self.cmd_tx
3453            .send(SessionCommand::SetMaxConnections {
3454                info_hash,
3455                limit,
3456                reply: tx,
3457            })
3458            .await
3459            .map_err(|_| crate::Error::Shutdown)?;
3460        rx.await.map_err(|_| crate::Error::Shutdown)?
3461    }
3462
3463    /// Get the current per-torrent maximum connection limit (0 = use global default).
3464    ///
3465    /// # Errors
3466    ///
3467    /// Returns an error if the connection or binding fails.
3468    pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3469        let (tx, rx) = oneshot::channel();
3470        self.cmd_tx
3471            .send(SessionCommand::MaxConnections {
3472                info_hash,
3473                reply: tx,
3474            })
3475            .await
3476            .map_err(|_| crate::Error::Shutdown)?;
3477        rx.await.map_err(|_| crate::Error::Shutdown)?
3478    }
3479
3480    /// Set the per-torrent maximum number of upload slots (unchoke slots).
3481    ///
3482    /// # Errors
3483    ///
3484    /// Returns an error if the data cannot be parsed or I/O fails.
3485    pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3486        let (tx, rx) = oneshot::channel();
3487        self.cmd_tx
3488            .send(SessionCommand::SetMaxUploads {
3489                info_hash,
3490                limit,
3491                reply: tx,
3492            })
3493            .await
3494            .map_err(|_| crate::Error::Shutdown)?;
3495        rx.await.map_err(|_| crate::Error::Shutdown)?
3496    }
3497
3498    /// Get the current per-torrent maximum upload slots (unchoke slots).
3499    ///
3500    /// # Errors
3501    ///
3502    /// Returns an error if the data cannot be parsed or I/O fails.
3503    pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3504        let (tx, rx) = oneshot::channel();
3505        self.cmd_tx
3506            .send(SessionCommand::MaxUploads {
3507                info_hash,
3508                reply: tx,
3509            })
3510            .await
3511            .map_err(|_| crate::Error::Shutdown)?;
3512        rx.await.map_err(|_| crate::Error::Shutdown)?
3513    }
3514
3515    /// Get per-peer details for all connected peers of a torrent.
3516    ///
3517    /// # Errors
3518    ///
3519    /// Returns an error if the session is shut down.
3520    pub async fn get_peer_info(
3521        &self,
3522        info_hash: Id20,
3523    ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3524        let (tx, rx) = oneshot::channel();
3525        self.cmd_tx
3526            .send(SessionCommand::GetPeerInfo {
3527                info_hash,
3528                reply: tx,
3529            })
3530            .await
3531            .map_err(|_| crate::Error::Shutdown)?;
3532        rx.await.map_err(|_| crate::Error::Shutdown)?
3533    }
3534
3535    /// Get in-flight piece download status for a torrent (the download queue).
3536    ///
3537    /// # Errors
3538    ///
3539    /// Returns an error if the data cannot be parsed or I/O fails.
3540    pub async fn get_download_queue(
3541        &self,
3542        info_hash: Id20,
3543    ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3544        let (tx, rx) = oneshot::channel();
3545        self.cmd_tx
3546            .send(SessionCommand::GetDownloadQueue {
3547                info_hash,
3548                reply: tx,
3549            })
3550            .await
3551            .map_err(|_| crate::Error::Shutdown)?;
3552        rx.await.map_err(|_| crate::Error::Shutdown)?
3553    }
3554
3555    /// Check whether a specific piece has been downloaded for a torrent.
3556    ///
3557    /// # Errors
3558    ///
3559    /// Returns an error if the session is shut down.
3560    pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3561        let (tx, rx) = oneshot::channel();
3562        self.cmd_tx
3563            .send(SessionCommand::HavePiece {
3564                info_hash,
3565                index,
3566                reply: tx,
3567            })
3568            .await
3569            .map_err(|_| crate::Error::Shutdown)?;
3570        rx.await.map_err(|_| crate::Error::Shutdown)?
3571    }
3572
3573    /// Get per-piece availability counts from connected peers for a torrent.
3574    ///
3575    /// # Errors
3576    ///
3577    /// Returns an error if the session is shut down.
3578    pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3579        let (tx, rx) = oneshot::channel();
3580        self.cmd_tx
3581            .send(SessionCommand::PieceAvailability {
3582                info_hash,
3583                reply: tx,
3584            })
3585            .await
3586            .map_err(|_| crate::Error::Shutdown)?;
3587        rx.await.map_err(|_| crate::Error::Shutdown)?
3588    }
3589
3590    /// Get per-file bytes-downloaded progress for a torrent.
3591    ///
3592    /// # Errors
3593    ///
3594    /// Returns an error if the session is shut down.
3595    pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3596        let (tx, rx) = oneshot::channel();
3597        self.cmd_tx
3598            .send(SessionCommand::FileProgress {
3599                info_hash,
3600                reply: tx,
3601            })
3602            .await
3603            .map_err(|_| crate::Error::Shutdown)?;
3604        rx.await.map_err(|_| crate::Error::Shutdown)?
3605    }
3606
3607    /// Get the torrent's identity hashes (v1 and/or v2).
3608    ///
3609    /// # Errors
3610    ///
3611    /// Returns an error if the session is shut down.
3612    pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3613        let (tx, rx) = oneshot::channel();
3614        self.cmd_tx
3615            .send(SessionCommand::InfoHashesQuery {
3616                info_hash,
3617                reply: tx,
3618            })
3619            .await
3620            .map_err(|_| crate::Error::Shutdown)?;
3621        rx.await.map_err(|_| crate::Error::Shutdown)?
3622    }
3623
3624    /// Get the full v1 metainfo for a torrent.
3625    ///
3626    /// Returns `None` for magnet links before metadata has been received.
3627    ///
3628    /// # Errors
3629    ///
3630    /// Returns an error if the session is shut down.
3631    pub async fn torrent_file(
3632        &self,
3633        info_hash: Id20,
3634    ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3635        let (tx, rx) = oneshot::channel();
3636        self.cmd_tx
3637            .send(SessionCommand::TorrentFile {
3638                info_hash,
3639                reply: tx,
3640            })
3641            .await
3642            .map_err(|_| crate::Error::Shutdown)?;
3643        rx.await.map_err(|_| crate::Error::Shutdown)?
3644    }
3645
3646    /// Get the full v2 metainfo for a torrent.
3647    ///
3648    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
3649    /// links before metadata has been received.
3650    ///
3651    /// # Errors
3652    ///
3653    /// Returns an error if the session is shut down.
3654    pub async fn torrent_file_v2(
3655        &self,
3656        info_hash: Id20,
3657    ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3658        let (tx, rx) = oneshot::channel();
3659        self.cmd_tx
3660            .send(SessionCommand::TorrentFileV2 {
3661                info_hash,
3662                reply: tx,
3663            })
3664            .await
3665            .map_err(|_| crate::Error::Shutdown)?;
3666        rx.await.map_err(|_| crate::Error::Shutdown)?
3667    }
3668
3669    /// **TEST-ONLY.** Synchronously inject an info-dict into a torrent's
3670    /// `MetadataDownloader` queue. Returns only after the actor has processed
3671    /// the message. Reuses the existing M147 internal handler at
3672    /// `torrent.rs:3665` via a separate test-only `TorrentCommand` variant
3673    /// (the production [`TorrentHandle::send_pre_resolved_metadata`] is
3674    /// fire-and-forget and stays unchanged).
3675    ///
3676    /// Introduced in v0.173.2 as a cross-crate test escape hatch for the
3677    /// `irontide-api` A9 HTTP integration test — it lets tests exercise the
3678    /// post-metadata HTTP surface without spinning up real peers.
3679    ///
3680    /// # Errors
3681    /// - [`crate::Error::TorrentNotFound`] if `info_hash` is not registered.
3682    /// - [`crate::Error::Shutdown`] if the session or torrent command
3683    ///   channel is closed.
3684    #[cfg(feature = "test-util")]
3685    pub async fn debug_inject_metadata(
3686        &self,
3687        info_hash: Id20,
3688        info_bytes: Vec<u8>,
3689    ) -> crate::Result<()> {
3690        let (tx, rx) = oneshot::channel();
3691        self.cmd_tx
3692            .send(SessionCommand::TestInjectMetadata {
3693                info_hash,
3694                info_bytes,
3695                reply: tx,
3696            })
3697            .await
3698            .map_err(|_| crate::Error::Shutdown)?;
3699        rx.await.map_err(|_| crate::Error::Shutdown)?
3700    }
3701
3702    /// Force an immediate DHT announce for a torrent.
3703    ///
3704    /// # Errors
3705    ///
3706    /// Returns an error if the session is shut down.
3707    pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3708        let (tx, rx) = oneshot::channel();
3709        self.cmd_tx
3710            .send(SessionCommand::ForceDhtAnnounce {
3711                info_hash,
3712                reply: tx,
3713            })
3714            .await
3715            .map_err(|_| crate::Error::Shutdown)?;
3716        rx.await.map_err(|_| crate::Error::Shutdown)?
3717    }
3718
3719    /// Force an immediate LSD (Local Service Discovery) announce for a torrent.
3720    ///
3721    /// LSD is a session-level component — this does not go through the torrent actor.
3722    ///
3723    /// # Errors
3724    ///
3725    /// Returns an error if the session is shut down.
3726    pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3727        let (tx, rx) = oneshot::channel();
3728        self.cmd_tx
3729            .send(SessionCommand::ForceLsdAnnounce {
3730                info_hash,
3731                reply: tx,
3732            })
3733            .await
3734            .map_err(|_| crate::Error::Shutdown)?;
3735        rx.await.map_err(|_| crate::Error::Shutdown)?
3736    }
3737
3738    /// Read all data for a specific piece from disk.
3739    ///
3740    /// # Errors
3741    ///
3742    /// Returns an error if the data cannot be parsed or I/O fails.
3743    pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3744        let (tx, rx) = oneshot::channel();
3745        self.cmd_tx
3746            .send(SessionCommand::ReadPiece {
3747                info_hash,
3748                index,
3749                reply: tx,
3750            })
3751            .await
3752            .map_err(|_| crate::Error::Shutdown)?;
3753        rx.await.map_err(|_| crate::Error::Shutdown)?
3754    }
3755
3756    /// Flush the disk write cache for a torrent.
3757    ///
3758    /// # Errors
3759    ///
3760    /// Returns an error if the session is shut down.
3761    pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3762        let (tx, rx) = oneshot::channel();
3763        self.cmd_tx
3764            .send(SessionCommand::FlushCache {
3765                info_hash,
3766                reply: tx,
3767            })
3768            .await
3769            .map_err(|_| crate::Error::Shutdown)?;
3770        rx.await.map_err(|_| crate::Error::Shutdown)?
3771    }
3772
3773    /// Check if a torrent exists in the session and its handle is still valid.
3774    pub async fn is_valid(&self, info_hash: Id20) -> bool {
3775        let (tx, rx) = oneshot::channel();
3776        if self
3777            .cmd_tx
3778            .send(SessionCommand::IsValid {
3779                info_hash,
3780                reply: tx,
3781            })
3782            .await
3783            .is_err()
3784        {
3785            return false;
3786        }
3787        rx.await.unwrap_or(false)
3788    }
3789
3790    /// Clear the error state on a torrent, resuming it if it was paused due to error.
3791    ///
3792    /// # Errors
3793    ///
3794    /// Returns an error if the session is shut down.
3795    pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3796        let (tx, rx) = oneshot::channel();
3797        self.cmd_tx
3798            .send(SessionCommand::ClearError {
3799                info_hash,
3800                reply: tx,
3801            })
3802            .await
3803            .map_err(|_| crate::Error::Shutdown)?;
3804        rx.await.map_err(|_| crate::Error::Shutdown)?
3805    }
3806
3807    /// Get per-file open/mode status for a torrent.
3808    ///
3809    /// # Errors
3810    ///
3811    /// Returns an error if the session is shut down.
3812    pub async fn file_status(
3813        &self,
3814        info_hash: Id20,
3815    ) -> crate::Result<Vec<crate::types::FileStatus>> {
3816        let (tx, rx) = oneshot::channel();
3817        self.cmd_tx
3818            .send(SessionCommand::FileStatus {
3819                info_hash,
3820                reply: tx,
3821            })
3822            .await
3823            .map_err(|_| crate::Error::Shutdown)?;
3824        rx.await.map_err(|_| crate::Error::Shutdown)?
3825    }
3826
3827    /// Read the current torrent flags as a [`crate::types::TorrentFlags`] bitflag set.
3828    ///
3829    /// # Errors
3830    ///
3831    /// Returns an error if the session is shut down.
3832    pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3833        let (tx, rx) = oneshot::channel();
3834        self.cmd_tx
3835            .send(SessionCommand::Flags {
3836                info_hash,
3837                reply: tx,
3838            })
3839            .await
3840            .map_err(|_| crate::Error::Shutdown)?;
3841        rx.await.map_err(|_| crate::Error::Shutdown)?
3842    }
3843
3844    /// Set (enable) the specified torrent flags.
3845    ///
3846    /// # Errors
3847    ///
3848    /// Returns an error if the session is shut down.
3849    pub async fn set_flags(
3850        &self,
3851        info_hash: Id20,
3852        flags: crate::types::TorrentFlags,
3853    ) -> crate::Result<()> {
3854        let (tx, rx) = oneshot::channel();
3855        self.cmd_tx
3856            .send(SessionCommand::SetFlags {
3857                info_hash,
3858                flags,
3859                reply: tx,
3860            })
3861            .await
3862            .map_err(|_| crate::Error::Shutdown)?;
3863        rx.await.map_err(|_| crate::Error::Shutdown)?
3864    }
3865
3866    /// Unset (disable) the specified torrent flags.
3867    ///
3868    /// # Errors
3869    ///
3870    /// Returns an error if the session is shut down.
3871    pub async fn unset_flags(
3872        &self,
3873        info_hash: Id20,
3874        flags: crate::types::TorrentFlags,
3875    ) -> crate::Result<()> {
3876        let (tx, rx) = oneshot::channel();
3877        self.cmd_tx
3878            .send(SessionCommand::UnsetFlags {
3879                info_hash,
3880                flags,
3881                reply: tx,
3882            })
3883            .await
3884            .map_err(|_| crate::Error::Shutdown)?;
3885        rx.await.map_err(|_| crate::Error::Shutdown)?
3886    }
3887
3888    /// Immediately initiate a peer connection for a torrent.
3889    ///
3890    /// # Errors
3891    ///
3892    /// Returns an error if the connection or binding fails.
3893    pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3894        let (tx, rx) = oneshot::channel();
3895        self.cmd_tx
3896            .send(SessionCommand::ConnectPeer {
3897                info_hash,
3898                addr,
3899                reply: tx,
3900            })
3901            .await
3902            .map_err(|_| crate::Error::Shutdown)?;
3903        rx.await.map_err(|_| crate::Error::Shutdown)?
3904    }
3905
3906    /// Store an immutable item in the DHT (BEP 44).
3907    ///
3908    /// Returns the SHA-1 target hash of the stored value.
3909    ///
3910    /// # Errors
3911    ///
3912    /// Returns an error if the session is shut down.
3913    pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3914        let (tx, rx) = oneshot::channel();
3915        self.cmd_tx
3916            .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3917            .await
3918            .map_err(|_| crate::Error::Shutdown)?;
3919        rx.await.map_err(|_| crate::Error::Shutdown)?
3920    }
3921
3922    /// Retrieve an immutable item from the DHT (BEP 44).
3923    ///
3924    /// Returns `Some(value)` if found, `None` otherwise.
3925    ///
3926    /// # Errors
3927    ///
3928    /// Returns an error if the session is shut down.
3929    pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3930        let (tx, rx) = oneshot::channel();
3931        self.cmd_tx
3932            .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3933            .await
3934            .map_err(|_| crate::Error::Shutdown)?;
3935        rx.await.map_err(|_| crate::Error::Shutdown)?
3936    }
3937
3938    /// Store a mutable item in the DHT (BEP 44).
3939    ///
3940    /// `keypair_bytes` is a 32-byte Ed25519 seed. Returns the target hash.
3941    ///
3942    /// # Errors
3943    ///
3944    /// Returns an error if the session is shut down.
3945    pub async fn dht_put_mutable(
3946        &self,
3947        keypair_bytes: [u8; 32],
3948        value: Vec<u8>,
3949        seq: i64,
3950        salt: Vec<u8>,
3951    ) -> crate::Result<Id20> {
3952        let (tx, rx) = oneshot::channel();
3953        self.cmd_tx
3954            .send(SessionCommand::DhtPutMutable {
3955                keypair_bytes,
3956                value,
3957                seq,
3958                salt,
3959                reply: tx,
3960            })
3961            .await
3962            .map_err(|_| crate::Error::Shutdown)?;
3963        rx.await.map_err(|_| crate::Error::Shutdown)?
3964    }
3965
3966    /// Retrieve a mutable item from the DHT (BEP 44).
3967    ///
3968    /// Returns `Some((value, seq))` if found, `None` otherwise.
3969    ///
3970    /// # Errors
3971    ///
3972    /// Returns an error if the session is shut down.
3973    pub async fn dht_get_mutable(
3974        &self,
3975        public_key: [u8; 32],
3976        salt: Vec<u8>,
3977    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3978        let (tx, rx) = oneshot::channel();
3979        self.cmd_tx
3980            .send(SessionCommand::DhtGetMutable {
3981                public_key,
3982                salt,
3983                reply: tx,
3984            })
3985            .await
3986            .map_err(|_| crate::Error::Shutdown)?;
3987        rx.await.map_err(|_| crate::Error::Shutdown)?
3988    }
3989
3990    // ── M121: Convenience API for future HTTP endpoints ──
3991
3992    /// List all torrents as lightweight summaries.
3993    ///
3994    /// M245 A1: reads the lock-free published [`SessionSnapshot`] — a single
3995    /// `Arc` load, no per-torrent command round-trips (the pre-M245 body issued
3996    /// one `stats()` round-trip per torrent on the actor's recv loop).
3997    /// Membership is read-after-write consistent (the actor patches it eagerly
3998    /// on add/remove); sampled stats are eventually-consistent to one stats
3999    /// tick. Summaries are ascending by info-hash.
4000    ///
4001    /// # Errors
4002    ///
4003    /// Never errors — the snapshot read is infallible. The `Result` is retained
4004    /// for facade API stability (M245 D5).
4005    #[allow(
4006        clippy::unused_async,
4007        reason = "async + Result signature kept for facade API stability (M245 D5); the snapshot read is synchronous and infallible"
4008    )]
4009    pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
4010        Ok(self.snapshot.load().summaries())
4011    }
4012
4013    /// Add a torrent from a magnet URI string.
4014    ///
4015    /// Parses the URI, extracts info hashes, and adds the magnet to the session.
4016    /// Returns the info hashes (v1 and/or v2) for the added torrent.
4017    ///
4018    /// # Errors
4019    ///
4020    /// Returns an error if the torrent cannot be added or the session is shut down.
4021    pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
4022        let magnet = irontide_core::Magnet::parse(uri)?;
4023        let info_hashes = magnet.info_hashes.clone();
4024        self.add_magnet(magnet).await?;
4025        Ok(info_hashes)
4026    }
4027
4028    /// Add a torrent from raw .torrent file bytes.
4029    ///
4030    /// Auto-detects v1, v2, or hybrid format. Returns the info hashes for the
4031    /// added torrent.
4032    ///
4033    /// # Errors
4034    ///
4035    /// Returns an error if the torrent cannot be added or the session is shut down.
4036    pub async fn add_torrent_bytes(
4037        &self,
4038        bytes: &[u8],
4039    ) -> crate::Result<irontide_core::InfoHashes> {
4040        let meta = irontide_core::torrent_from_bytes_any(bytes)?;
4041        let info_hashes = meta.info_hashes();
4042        self.add_torrent_with_meta(meta, None).await?;
4043        Ok(info_hashes)
4044    }
4045}
4046
4047// ---------------------------------------------------------------------------
4048// SessionSnapshot — M245 A1 published lock-free read-model
4049// ---------------------------------------------------------------------------
4050
4051/// Lock-free read-model published by the [`SessionActor`] (M245 A1).
4052///
4053/// **Consistency contract (hybrid):**
4054/// - *Membership* (which torrents exist) is **read-after-write consistent** — the
4055///   actor patches this map synchronously on add/remove, so a torrent is visible
4056///   the instant its add commits.
4057/// - *Sampled stats* (rates, progress, peer counts) are **eventually-consistent to
4058///   one stats tick** — the same skew any sampled counter already carries.
4059///
4060/// Held in an [`arc_swap::ArcSwap`] so read-only callers clone an `Arc` snapshot
4061/// without touching the mutating command mailbox.
4062#[derive(Debug, Default, Clone)]
4063pub struct SessionSnapshot {
4064    by_id: std::collections::BTreeMap<Id20, TorrentSummary>,
4065}
4066
4067impl SessionSnapshot {
4068    /// All torrent summaries, ascending by info-hash. Owned clone — callers that
4069    /// only need the count or a scan can use [`SessionSnapshot::as_map`] instead.
4070    #[must_use]
4071    pub fn summaries(&self) -> Vec<TorrentSummary> {
4072        self.by_id.values().cloned().collect()
4073    }
4074
4075    /// Internal: the keyed map, for the actor's O(log n) membership patches
4076    /// (eager add/remove) and the per-tick stats refresh carry-forward.
4077    pub(crate) fn as_map(&self) -> &std::collections::BTreeMap<Id20, TorrentSummary> {
4078        &self.by_id
4079    }
4080
4081    /// Internal: build a snapshot from a patched map (actor-side only).
4082    pub(crate) fn from_map(by_id: std::collections::BTreeMap<Id20, TorrentSummary>) -> Self {
4083        Self { by_id }
4084    }
4085}
4086
4087// ---------------------------------------------------------------------------
4088// SessionActor — internal single-owner event loop
4089// ---------------------------------------------------------------------------
4090
4091struct SessionActor {
4092    settings: Settings,
4093    /// M223 — self-loopback sender, so the spawned `handle_add_torrent`
4094    /// prep tasks can route their `CommitAddTorrent` result back through
4095    /// the same recv loop that owns the mutating session state.
4096    /// Cloned from the same sender the `SessionHandle` holds; carries
4097    /// the M221.1a timing wrapper so the commit hop is observable in
4098    /// the `queue_wait_ms` / `handler_ms` telemetry.
4099    commit_tx: SessionCmdSender,
4100    torrents: HashMap<Id20, TorrentEntry>,
4101    /// M245 A1 — the published read-model the [`SessionHandle`] reads from.
4102    /// Same `ArcSwap` the handle holds (cloned at construction). The actor is
4103    /// the SOLE writer: an eager membership patch on every add/remove (so
4104    /// reads are read-after-write consistent) plus a sampled-stats refresh
4105    /// derived from the per-tick fan-out in [`make_session_stats`](Self::make_session_stats).
4106    snapshot: Arc<arc_swap::ArcSwap<SessionSnapshot>>,
4107    dht_v4: Option<DhtHandle>,
4108    dht_v6: Option<DhtHandle>,
4109    /// M173 Lane B (B6): broadcast surface for runtime `DhtHandle`
4110    /// replacement. `TorrentActor` consumers borrow a `DhtReceiver`
4111    /// instead of cloning `DhtHandle`, so a session-side DHT restart
4112    /// (B11) propagates to every torrent on the next `borrow()`.
4113    /// Initialised once at session start with the same value as the
4114    /// `dht_v4`/`dht_v6` clones; never written until B11.
4115    dht_v4_broadcast: irontide_dht::DhtBroadcast,
4116    dht_v6_broadcast: irontide_dht::DhtBroadcast,
4117    lsd: Option<crate::lsd::LsdHandle>,
4118    lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
4119    cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
4120    alert_tx: broadcast::Sender<Alert>,
4121    alert_mask: Arc<AtomicU32>,
4122    global_upload_bucket: SharedBucket,
4123    global_download_bucket: SharedBucket,
4124    utp_socket: Option<irontide_utp::UtpSocket>,
4125    utp_socket_v6: Option<irontide_utp::UtpSocket>,
4126    nat: Option<irontide_nat::NatHandle>,
4127    nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
4128    ban_manager: SharedBanManager,
4129    ip_filter: SharedIpFilter,
4130    disk_manager: crate::disk::DiskManagerHandle,
4131    #[allow(dead_code)]
4132    disk_actor_handle: tokio::task::JoinHandle<()>,
4133    /// External IP discovered via NAT traversal or configured manually (BEP 40).
4134    external_ip: Option<std::net::IpAddr>,
4135    /// M251/ER3: the TCP port a NAT mapping succeeded for. Sticky — set on
4136    /// `NatEvent::MappingSucceeded { protocol: "TCP" }`, never cleared (a
4137    /// renewal/rebind fires a fresh success; clearing on a *different*
4138    /// protocol's failure would drop a live mapping).
4139    external_tcp_port: Option<u16>,
4140    /// M251/ER6: monotonic count of validated inbound peer connections
4141    /// routed to a torrent (TCP + SSL listener paths).
4142    incoming_peer_connections: u64,
4143    /// BEP 42: External IP consensus from DHT v4 KRPC responses.
4144    dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
4145    /// BEP 42: External IP consensus from DHT v6 KRPC responses.
4146    dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
4147    /// Registered extension plugins, shared with all `TorrentActors`.
4148    plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
4149    /// I2P SAM session (if enabled).
4150    sam_session: Option<Arc<crate::i2p::SamSession>>,
4151    /// SSL manager for SSL torrent certificate handling (M42).
4152    ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
4153    /// SSL/TLS TCP listener (separate port from the main listener) (M42).
4154    ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
4155    /// Channel receiving pre-validated inbound connections from the `ListenerTask` (M114).
4156    validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
4157    /// Registry of active info hashes shared with the `ListenerTask` (M114).
4158    /// INVARIANT: Must be updated whenever torrents are added/removed.
4159    /// If a new torrent-add path is added without updating this registry,
4160    /// inbound connections for that torrent will be silently rejected.
4161    info_hash_registry: Arc<DashMap<Id20, ()>>,
4162    /// Handle to keep the listener task alive; dropped on session shutdown
4163    /// (M114). M173 Lane B (B2) replaced the bare `JoinHandle` with
4164    /// `ListenerHandle` so the listen-port rebind path (B4) can call
4165    /// `shutdown_with_timeout` for a clean port swap.
4166    #[allow(dead_code)] // used by Drop sequence + B4 listen-port rebind
4167    _listener_task: crate::listener::ListenerHandle,
4168    /// M224 D3: global TCP-inbound connection cap, shared with the
4169    /// `ListenerTask`. `-1` = unlimited. Updated atomically from
4170    /// `handle_apply_settings` so the listener sees the new value on the
4171    /// next accept without restarting the task.
4172    max_connections_global: Arc<std::sync::atomic::AtomicI32>,
4173    /// M224 D3: live TCP-inbound connection count, shared with the
4174    /// `ListenerTask`. Incremented at TCP accept; decremented when the
4175    /// `LiveConnectionGuard` attached to the accepted connection drops.
4176    /// Kept here so future milestones (M225 outbound, observability) can
4177    /// read the live count without touching the listener.
4178    #[allow(dead_code)] // read via the M224 G1 integration test + future M225 observability.
4179    live_connections: Arc<std::sync::atomic::AtomicUsize>,
4180    /// Shared atomic session counters (M50).
4181    counters: Arc<crate::stats::SessionCounters>,
4182    /// Network transport factory for TCP operations (M51).
4183    factory: Arc<crate::transport::NetworkFactory>,
4184    /// Shared hash pool for parallel piece verification (M96).
4185    hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
4186    /// M170: qBt-compat category registry, shared with CRUD call paths.
4187    category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
4188    /// M171: qBt-compat tag registry, shared with CRUD call paths.
4189    tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
4190    /// M170: info hashes currently in the `remove_torrent_with_files`
4191    /// grace period. Guards against fast delete-then-re-add sequences
4192    /// that would otherwise race the file-deletion walker.
4193    deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
4194    /// M173 Lane B: in-flight `apply_settings` guard. Concurrent
4195    /// `setPreferences` calls hit `ApplyError::ConcurrentReconfig`
4196    /// rather than interleaving rate-limiter / listener-rebind /
4197    /// DHT-restart phases. Wired by B11.
4198    #[allow(dead_code)] // wired by B11 — concurrent setPreferences guard
4199    reconfig_in_flight: crate::apply::ReconfigInFlight,
4200    self_alert_rx: broadcast::Receiver<Alert>,
4201    resume_save_notify: Arc<tokio::sync::Notify>,
4202    /// M241 L1 / F4: single-flight guard serializing every off-loop resume save.
4203    /// `atomic_write` uses a fixed `<hash>.resume.tmp` temp path, so two
4204    /// concurrent saves of the same torrent would race that temp and corrupt the
4205    /// file. The periodic tick uses `try_lock_owned` (skip when busy); the
4206    /// `SaveResumeState` RPC + the inline shutdown save wait on it.
4207    resume_save_lock: Arc<tokio::sync::Mutex<()>>,
4208    /// M226 Step 5: live settings broadcast to the engine-side OS
4209    /// notification dispatcher spawned in `start_full`. Updated from
4210    /// `handle_apply_settings` so `notify_on_complete` /
4211    /// `notify_on_error` toggles take effect on the very next alert
4212    /// without restarting the dispatcher task.
4213    notification_settings_tx: tokio::sync::watch::Sender<Settings>,
4214    /// M226 Step 5: held alive only for its `Drop` — when the
4215    /// `SessionActor` ends and this field drops, the matching
4216    /// `oneshot::Receiver` in the dispatcher resolves and the
4217    /// dispatcher loop exits cleanly. Belt-and-suspenders with the
4218    /// `alert_tx` broadcast closure (the dispatcher also exits on
4219    /// `RecvError::Closed`), since `alert_tx` is held by every
4220    /// outstanding `SessionHandle::subscribe()` consumer and may
4221    /// outlive the actor.
4222    #[allow(dead_code)]
4223    notification_shutdown_tx: oneshot::Sender<()>,
4224    /// M226 Step 6: triggers the watched-folder dispatcher to drop
4225    /// its current debouncer (releasing inotify FDs) and rebuild
4226    /// against the new path. Pinged by `handle_apply_settings` when
4227    /// the `SettingsDelta` carries a change on `watched_folder` or
4228    /// `delete_torrent_after_add`. Separate from the Settings watch
4229    /// channel because rate-limit / DHT / mask changes shouldn't
4230    /// cause inotify churn.
4231    watched_folder_changed: Arc<tokio::sync::Notify>,
4232    /// M226 Step 6: same Drop-as-shutdown pattern as
4233    /// [`Self::notification_shutdown_tx`].
4234    #[allow(dead_code)]
4235    watched_folder_shutdown_tx: oneshot::Sender<()>,
4236    /// M255/ER1: per-peer country resolver — `Some` only while
4237    /// `resolve_peer_countries` is enabled with an openable DB. Rebuilt
4238    /// delta-keyed in `handle_apply_settings`.
4239    geoip: Option<crate::geoip::GeoIpResolver>,
4240}
4241
4242impl SessionActor {
4243    /// v0.173.1: shared helper for reader sites that used to read
4244    /// `TorrentEntry.meta` (a stale cache that was silently `None` forever
4245    /// for magnet-added torrents). Always queries the `TorrentActor` — the
4246    /// single source of truth for torrent metadata post-v0.173.1.
4247    ///
4248    /// Returns [`crate::Error::TorrentNotFound`] if `info_hash` isn't in the
4249    /// registry, [`crate::Error::MetadataNotReady`] if the actor has not yet
4250    /// assembled metadata (magnet pre-resolution), or propagates
4251    /// [`crate::Error::Shutdown`] if the actor has already exited.
4252    async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4253        let entry = self
4254            .torrents
4255            .get(&info_hash)
4256            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4257        entry
4258            .handle
4259            .get_meta()
4260            .await?
4261            .ok_or(crate::Error::MetadataNotReady(info_hash))
4262    }
4263
4264    async fn run(mut self) {
4265        let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4266        refill_interval.tick().await; // skip first immediate tick
4267
4268        let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4269        let mut auto_manage_interval =
4270            tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4271        auto_manage_interval.tick().await; // skip first immediate tick
4272
4273        // Periodic session stats timer (M50)
4274        let stats_interval_ms = self.settings.stats_report_interval;
4275        let mut stats_timer = if stats_interval_ms > 0 {
4276            Some(tokio::time::interval(std::time::Duration::from_millis(
4277                stats_interval_ms,
4278            )))
4279        } else {
4280            None
4281        };
4282        if let Some(ref mut t) = stats_timer {
4283            t.tick().await; // skip first immediate tick
4284        }
4285
4286        // Periodic sample_infohashes timer (BEP 51, M111)
4287        let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4288        let mut sample_timer = if sample_interval_secs > 0 {
4289            Some(tokio::time::interval(std::time::Duration::from_secs(
4290                sample_interval_secs,
4291            )))
4292        } else {
4293            None
4294        };
4295        if let Some(ref mut t) = sample_timer {
4296            t.tick().await; // skip first immediate tick
4297        }
4298
4299        // Periodic resume file save timer (M161)
4300        let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4301            Some(tokio::time::interval(std::time::Duration::from_secs(
4302                self.settings.save_resume_interval_secs,
4303            )))
4304        } else {
4305            None
4306        };
4307        if let Some(ref mut t) = resume_save_interval {
4308            t.tick().await; // skip first immediate tick
4309        }
4310
4311        // Auto-restore torrents from resume files on startup (M161 Phase 5).
4312        {
4313            let resume_dir = self.effective_resume_dir();
4314            let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4315            if !resume_files.is_empty() {
4316                // Reuse the existing sequential restore logic from Phase 4.
4317                match self.handle_load_resume_state().await {
4318                    Ok(result) => {
4319                        info!(
4320                            restored = result.restored,
4321                            skipped = result.skipped,
4322                            failed = result.failed,
4323                            "auto-restored torrents on startup"
4324                        );
4325                    }
4326                    Err(e) => {
4327                        warn!("auto-restore on startup failed: {e}");
4328                    }
4329                }
4330
4331                // Init throttle: restored torrents were queued inside
4332                // handle_load_resume_state when queueing_enabled. Run one
4333                // immediate evaluate_queue() so up to active_checking
4334                // torrents enter Checking right away (don't wait 30s).
4335                if self.settings.queueing_enabled {
4336                    self.evaluate_queue().await;
4337                }
4338
4339                // Orphan cleanup: delete .resume files whose hex stem does not
4340                // match any info hash currently in the session.
4341                let active_hashes: std::collections::HashSet<String> = self
4342                    .torrents
4343                    .keys()
4344                    .map(|h| hex::encode(h.as_bytes()))
4345                    .collect();
4346
4347                // Re-scan after load — some files may have been consumed.
4348                let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4349                for path in &current_files {
4350                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4351                        && !active_hashes.contains(stem)
4352                    {
4353                        if let Err(e) = std::fs::remove_file(path) {
4354                            warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4355                        } else {
4356                            debug!(path = %path.display(), "removed orphan resume file");
4357                        }
4358                    }
4359                }
4360            }
4361        }
4362
4363        loop {
4364            tokio::select! {
4365                cmd = self.cmd_rx.recv() => {
4366                    // M221.1a — per-command timing. `sent_at` is the
4367                    // instant the sender enqueued the command (via
4368                    // `SessionCmdSender::send`); `recv_at` is now.
4369                    // `queue_wait_ms` measures actor backlog; the
4370                    // post-match `handler_ms` covers the dispatch.
4371                    // Shutdown/None paths return early and skip the
4372                    // tracing emit — telemetry is intentionally absent
4373                    // for the terminal command.
4374                    let recv_at = tokio::time::Instant::now();
4375                    let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4376                        recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4377                    });
4378                    let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4379                    let handler_start = tokio::time::Instant::now();
4380                    let cmd = cmd.map(|(_sent_at, c)| c);
4381                    match cmd {
4382                        Some(SessionCommand::AddTorrent {
4383                            meta,
4384                            storage,
4385                            download_dir,
4386                            reply,
4387                        }) => {
4388                            // M223 — spawn-per-add path. The heavy
4389                            // `disk_manager.register_torrent` +
4390                            // `TorrentHandle::from_torrent` work runs in a
4391                            // `tokio::spawn`'d task; results route back via
4392                            // `SessionCommand::CommitAddTorrent`. Legacy entry
4393                            // point: no tags available from the pre-M171
4394                            // command shape.
4395                            let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4396                                let info_hash = meta.as_v1().map_or_else(
4397                                    || meta.info_hashes().best_v1(),
4398                                    |v| v.info_hash,
4399                                );
4400                                if self.torrents.contains_key(&info_hash) {
4401                                    return Err(crate::Error::DuplicateTorrent(info_hash));
4402                                }
4403                                if self.torrents.len() >= self.settings.max_torrents {
4404                                    return Err(crate::Error::SessionAtCapacity(
4405                                        self.settings.max_torrents,
4406                                    ));
4407                                }
4408                                Ok(self.build_add_torrent_prep_bundle(
4409                                    *meta,
4410                                    storage,
4411                                    download_dir,
4412                                    Vec::new(),
4413                                    &AddConfigOverrides::default(),
4414                                    None,
4415                                ))
4416                            })();
4417                            match setup {
4418                                Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4419                                Err(e) => {
4420                                    let _ = reply.send(Err(e));
4421                                }
4422                            }
4423                        }
4424                        Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4425                            // M223 — feedback variant: the spawn-per-add prep
4426                            // phase finished. `commit_add_torrent` does the
4427                            // mutating fixup on the actor (insert into
4428                            // `self.torrents`, queue position, alert, LSD,
4429                            // M170 post-hooks) using the precomputed
4430                            // `is_private` from the bundle.
4431                            let id = self.commit_add_torrent(result).await;
4432                            let _ = reply.send(id);
4433                        }
4434                        Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4435                            // Legacy entry point: no tags available from the
4436                            // pre-M171 command shape.
4437                            let result = self
4438                                .handle_add_magnet(
4439                                    magnet,
4440                                    download_dir,
4441                                    Vec::new(),
4442                                    AddConfigOverrides::default(),
4443                                )
4444                                .await;
4445                            let _ = reply.send(result);
4446                        }
4447                        Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4448                            let result = self.handle_remove_torrent(info_hash).await;
4449                            let _ = reply.send(result);
4450                        }
4451                        Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4452                            let result = self.handle_pause_torrent(info_hash).await;
4453                            let _ = reply.send(result);
4454                        }
4455                        Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4456                            let result = self.handle_resume_torrent(info_hash).await;
4457                            let _ = reply.send(result);
4458                        }
4459                        Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4460                            let result = self.handle_force_resume_torrent(info_hash).await;
4461                            let _ = reply.send(result);
4462                        }
4463                        Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4464                            let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4465                            let _ = reply.send(result);
4466                        }
4467                        Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4468                            let result = self.handle_torrent_stats(info_hash).await;
4469                            let _ = reply.send(result);
4470                        }
4471                        Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4472                            // v0.173.1: handle_torrent_info is now async because
4473                            // it queries the TorrentActor for metadata (Class A
4474                            // architectural fix — no more TorrentEntry.meta cache).
4475                            let result = self.handle_torrent_info(info_hash).await;
4476                            let _ = reply.send(result);
4477                        }
4478                        Some(SessionCommand::ListTorrents { reply }) => {
4479                            let list: Vec<Id20> = self.torrents.keys().copied().collect();
4480                            let _ = reply.send(list);
4481                        }
4482                        Some(SessionCommand::SessionStats { reply }) => {
4483                            let stats = self.make_session_stats().await;
4484                            let _ = reply.send(stats);
4485                        }
4486                        Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4487                            let result = self.handle_save_torrent_resume(info_hash).await;
4488                            let _ = reply.send(result);
4489                        }
4490                        Some(SessionCommand::SaveSessionState { reply }) => {
4491                            let result = self.handle_save_session_state().await;
4492                            let _ = reply.send(result);
4493                        }
4494                        Some(SessionCommand::LoadResumeState { reply }) => {
4495                            let result = self.handle_load_resume_state().await;
4496                            let _ = reply.send(result);
4497                        }
4498                        Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4499                            let result = match self.torrents.get(&info_hash) {
4500                                Some(entry) => Ok(entry.queue_position),
4501                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4502                            };
4503                            let _ = reply.send(result);
4504                        }
4505                        Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4506                            let result = self.handle_set_queue_position(info_hash, pos);
4507                            let _ = reply.send(result);
4508                        }
4509                        Some(SessionCommand::SetAutoManaged { info_hash, enabled, reply }) => {
4510                            let _ = reply.send(self.set_auto_managed_inner(info_hash, enabled));
4511                        }
4512                        Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4513                            let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4514                            let _ = reply.send(result);
4515                        }
4516                        Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4517                            let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4518                            let _ = reply.send(result);
4519                        }
4520                        Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4521                            let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4522                            let _ = reply.send(result);
4523                        }
4524                        Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4525                            let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4526                            let _ = reply.send(result);
4527                        }
4528                        Some(SessionCommand::BanPeer { ip, reply }) => {
4529                            self.ban_manager.write().ban(ip);
4530                            let _ = reply.send(());
4531                        }
4532                        Some(SessionCommand::UnbanPeer { ip, reply }) => {
4533                            let was_banned = self.ban_manager.write().unban(&ip);
4534                            let _ = reply.send(was_banned);
4535                        }
4536                        Some(SessionCommand::BannedPeers { reply }) => {
4537                            let list: Vec<IpAddr> = self.ban_manager.read()
4538                                .banned_list().iter().copied().collect();
4539                            let _ = reply.send(list);
4540                        }
4541                        Some(SessionCommand::SetIpFilter { filter, reply }) => {
4542                            *self.ip_filter.write() = filter;
4543                            let _ = reply.send(());
4544                        }
4545                        Some(SessionCommand::GetIpFilter { reply }) => {
4546                            let filter = self.ip_filter.read().clone();
4547                            let _ = reply.send(filter);
4548                        }
4549                        Some(SessionCommand::GetSettings { reply }) => {
4550                            let _ = reply.send(self.settings.clone());
4551                        }
4552                        Some(SessionCommand::ApplySettings { settings, reply }) => {
4553                            let result = self.handle_apply_settings(*settings);
4554                            let _ = reply.send(result);
4555                        }
4556                        Some(SessionCommand::DhtNodeCount { reply }) => {
4557                            // Sum routing-table sizes across both DHT
4558                            // instances. Either instance erroring or being
4559                            // absent contributes 0 — the qBt wire field is
4560                            // a best-effort gauge, not a strict invariant.
4561                            let mut total: usize = 0;
4562                            if let Some(dht) = &self.dht_v4
4563                                && let Ok(c) = dht.node_count().await
4564                            {
4565                                total += c;
4566                            }
4567                            if let Some(dht) = &self.dht_v6
4568                                && let Ok(c) = dht.node_count().await
4569                            {
4570                                total += c;
4571                            }
4572                            let _ = reply.send(total);
4573                        }
4574                        Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4575                            let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4576                            let _ = reply.send(result);
4577                        }
4578                        Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4579                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4580                                entry.handle.add_peers(peers, source).await
4581                            } else {
4582                                Err(crate::Error::TorrentNotFound(info_hash))
4583                            };
4584                            let _ = reply.send(result);
4585                        }
4586                        Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4587                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4588                                entry.handle.open_file(file_index).await
4589                            } else {
4590                                Err(crate::Error::TorrentNotFound(info_hash))
4591                            };
4592                            let _ = reply.send(result);
4593                        }
4594                        Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4595                            let result = match self.torrents.get(&info_hash) {
4596                                Some(entry) => {
4597                                    entry.handle.force_reannounce().await
4598                                }
4599                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4600                            };
4601                            let _ = reply.send(result);
4602                        }
4603                        Some(SessionCommand::TrackerList { info_hash, reply }) => {
4604                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4605                                entry.handle.tracker_list().await
4606                            } else {
4607                                Err(crate::Error::TorrentNotFound(info_hash))
4608                            };
4609                            let _ = reply.send(result);
4610                        }
4611                        Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4612                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4613                                entry.handle.peer_source_counts().await
4614                            } else {
4615                                Err(crate::Error::TorrentNotFound(info_hash))
4616                            };
4617                            let _ = reply.send(result);
4618                        }
4619                        Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4620                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4621                                entry.handle.query_unchoke_durations().await.ok()
4622                            } else {
4623                                None
4624                            };
4625                            let _ = reply.send(result);
4626                        }
4627                        Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4628                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4629                                entry.handle.get_web_seed_stats().await
4630                            } else {
4631                                Err(crate::Error::TorrentNotFound(info_hash))
4632                            };
4633                            let _ = reply.send(result);
4634                        }
4635                        Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4636                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4637                                entry.handle.get_web_seeds().await
4638                            } else {
4639                                Err(crate::Error::TorrentNotFound(info_hash))
4640                            };
4641                            let _ = reply.send(result);
4642                        }
4643                        Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4644                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4645                                entry.handle.get_piece_states().await
4646                            } else {
4647                                Err(crate::Error::TorrentNotFound(info_hash))
4648                            };
4649                            let _ = reply.send(result);
4650                        }
4651                        Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4652                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4653                                entry.handle.get_piece_hashes(offset, limit).await
4654                            } else {
4655                                Err(crate::Error::TorrentNotFound(info_hash))
4656                            };
4657                            let _ = reply.send(result);
4658                        }
4659                        Some(SessionCommand::Scrape { info_hash, reply }) => {
4660                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4661                                entry.handle.scrape().await
4662                            } else {
4663                                Err(crate::Error::TorrentNotFound(info_hash))
4664                            };
4665                            let _ = reply.send(result);
4666                        }
4667                        Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4668                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4669                                entry.handle.set_file_priority(index, priority).await
4670                            } else {
4671                                Err(crate::Error::TorrentNotFound(info_hash))
4672                            };
4673                            let _ = reply.send(result);
4674                        }
4675                        Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4676                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4677                                entry.handle.file_priorities().await
4678                            } else {
4679                                Err(crate::Error::TorrentNotFound(info_hash))
4680                            };
4681                            let _ = reply.send(result);
4682                        }
4683                        Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4684                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4685                                entry.handle.set_download_limit(bytes_per_sec).await
4686                            } else {
4687                                Err(crate::Error::TorrentNotFound(info_hash))
4688                            };
4689                            let _ = reply.send(result);
4690                        }
4691                        Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4692                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4693                                entry.handle.set_upload_limit(bytes_per_sec).await
4694                            } else {
4695                                Err(crate::Error::TorrentNotFound(info_hash))
4696                            };
4697                            let _ = reply.send(result);
4698                        }
4699                        Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4700                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4701                                entry.handle.download_limit().await
4702                            } else {
4703                                Err(crate::Error::TorrentNotFound(info_hash))
4704                            };
4705                            let _ = reply.send(result);
4706                        }
4707                        Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4708                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4709                                entry.handle.upload_limit().await
4710                            } else {
4711                                Err(crate::Error::TorrentNotFound(info_hash))
4712                            };
4713                            let _ = reply.send(result);
4714                        }
4715                        Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4716                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4717                                entry.handle.set_sequential_download(enabled).await
4718                            } else {
4719                                Err(crate::Error::TorrentNotFound(info_hash))
4720                            };
4721                            let _ = reply.send(result);
4722                        }
4723                        Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4724                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4725                                entry.handle.is_sequential_download().await
4726                            } else {
4727                                Err(crate::Error::TorrentNotFound(info_hash))
4728                            };
4729                            let _ = reply.send(result);
4730                        }
4731                        Some(SessionCommand::SetPrioritizeFirstLastPieces { info_hash, enabled, reply }) => {
4732                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4733                                entry.handle.set_prioritize_first_last_pieces(enabled).await
4734                            } else {
4735                                Err(crate::Error::TorrentNotFound(info_hash))
4736                            };
4737                            let _ = reply.send(result);
4738                        }
4739                        Some(SessionCommand::IsPrioritizeFirstLastPieces { info_hash, reply }) => {
4740                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4741                                entry.handle.is_prioritize_first_last_pieces().await
4742                            } else {
4743                                Err(crate::Error::TorrentNotFound(info_hash))
4744                            };
4745                            let _ = reply.send(result);
4746                        }
4747                        Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4748                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4749                                entry.handle.set_super_seeding(enabled).await
4750                            } else {
4751                                Err(crate::Error::TorrentNotFound(info_hash))
4752                            };
4753                            let _ = reply.send(result);
4754                        }
4755                        Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4756                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4757                                entry.handle.is_super_seeding().await
4758                            } else {
4759                                Err(crate::Error::TorrentNotFound(info_hash))
4760                            };
4761                            let _ = reply.send(result);
4762                        }
4763                        Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4764                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4765                                entry.handle.set_seed_mode(enabled).await
4766                            } else {
4767                                Err(crate::Error::TorrentNotFound(info_hash))
4768                            };
4769                            let _ = reply.send(result);
4770                        }
4771                        Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4772                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4773                                entry.handle.add_tracker(url).await
4774                            } else {
4775                                Err(crate::Error::TorrentNotFound(info_hash))
4776                            };
4777                            let _ = reply.send(result);
4778                        }
4779                        Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4780                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4781                                entry.handle.replace_trackers(urls).await
4782                            } else {
4783                                Err(crate::Error::TorrentNotFound(info_hash))
4784                            };
4785                            let _ = reply.send(result);
4786                        }
4787                        Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4788                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4789                                entry.handle.force_recheck().await
4790                            } else {
4791                                Err(crate::Error::TorrentNotFound(info_hash))
4792                            };
4793                            let _ = reply.send(result);
4794                        }
4795                        Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4796                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4797                                entry.handle.rename_file(file_index, new_name).await
4798                            } else {
4799                                Err(crate::Error::TorrentNotFound(info_hash))
4800                            };
4801                            let _ = reply.send(result);
4802                        }
4803                        Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4804                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4805                                entry.handle.set_max_connections(limit).await
4806                            } else {
4807                                Err(crate::Error::TorrentNotFound(info_hash))
4808                            };
4809                            let _ = reply.send(result);
4810                        }
4811                        Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4812                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4813                                entry.handle.max_connections().await
4814                            } else {
4815                                Err(crate::Error::TorrentNotFound(info_hash))
4816                            };
4817                            let _ = reply.send(result);
4818                        }
4819                        Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4820                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4821                                entry.handle.set_max_uploads(limit).await
4822                            } else {
4823                                Err(crate::Error::TorrentNotFound(info_hash))
4824                            };
4825                            let _ = reply.send(result);
4826                        }
4827                        Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4828                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4829                                entry.handle.max_uploads().await
4830                            } else {
4831                                Err(crate::Error::TorrentNotFound(info_hash))
4832                            };
4833                            let _ = reply.send(result);
4834                        }
4835                        Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4836                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4837                                entry.handle.get_peer_info().await.map(|mut peers| {
4838                                    // M255: single stamping point — every
4839                                    // consumer funnels through this arm.
4840                                    crate::geoip::stamp_country_codes(
4841                                        &mut peers,
4842                                        self.geoip.as_ref(),
4843                                    );
4844                                    peers
4845                                })
4846                            } else {
4847                                Err(crate::Error::TorrentNotFound(info_hash))
4848                            };
4849                            let _ = reply.send(result);
4850                        }
4851                        Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4852                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4853                                entry.handle.get_download_queue().await
4854                            } else {
4855                                Err(crate::Error::TorrentNotFound(info_hash))
4856                            };
4857                            let _ = reply.send(result);
4858                        }
4859                        Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4860                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4861                                entry.handle.have_piece(index).await
4862                            } else {
4863                                Err(crate::Error::TorrentNotFound(info_hash))
4864                            };
4865                            let _ = reply.send(result);
4866                        }
4867                        Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4868                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4869                                entry.handle.piece_availability().await
4870                            } else {
4871                                Err(crate::Error::TorrentNotFound(info_hash))
4872                            };
4873                            let _ = reply.send(result);
4874                        }
4875                        Some(SessionCommand::FileProgress { info_hash, reply }) => {
4876                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4877                                entry.handle.file_progress().await
4878                            } else {
4879                                Err(crate::Error::TorrentNotFound(info_hash))
4880                            };
4881                            let _ = reply.send(result);
4882                        }
4883                        Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4884                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4885                                entry.handle.info_hashes().await
4886                            } else {
4887                                Err(crate::Error::TorrentNotFound(info_hash))
4888                            };
4889                            let _ = reply.send(result);
4890                        }
4891                        Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4892                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4893                                entry.handle.torrent_file().await
4894                            } else {
4895                                Err(crate::Error::TorrentNotFound(info_hash))
4896                            };
4897                            let _ = reply.send(result);
4898                        }
4899                        Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4900                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4901                                entry.handle.torrent_file_v2().await
4902                            } else {
4903                                Err(crate::Error::TorrentNotFound(info_hash))
4904                            };
4905                            let _ = reply.send(result);
4906                        }
4907                        #[cfg(feature = "test-util")]
4908                        Some(SessionCommand::TestInjectMetadata {
4909                            info_hash,
4910                            info_bytes,
4911                            reply,
4912                        }) => {
4913                            let result = match self.torrents.get(&info_hash) {
4914                                Some(entry) => {
4915                                    entry.handle.test_inject_metadata(info_bytes).await
4916                                }
4917                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4918                            };
4919                            let _ = reply.send(result);
4920                        }
4921                        Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4922                            // v0.173.2: BEP 27 enforcement on the DHT path. M173.1 fixed the
4923                            // LSD leak vector but missed DHT — private torrents would still
4924                            // announce their info hash to DHT and leak peer IPs. Mirrors the
4925                            // LSD pattern at session.rs:3541-3563.
4926                            let result = match self.torrents.get(&info_hash) {
4927                                Some(entry) => {
4928                                    if entry.is_private().await {
4929                                        Err(crate::Error::InvalidSettings(
4930                                            "DHT disabled for private torrent".into(),
4931                                        ))
4932                                    } else {
4933                                        entry.handle.force_dht_announce().await
4934                                    }
4935                                }
4936                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4937                            };
4938                            let _ = reply.send(result);
4939                        }
4940                        Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4941                            // LSD is session-level: verify the torrent exists, then announce directly.
4942                            //
4943                            // v0.173.1: is_private is now async. Match guards can't be async, so
4944                            // evaluate the flag up front and branch on the bool.
4945                            let result = match self.torrents.get(&info_hash) {
4946                                Some(entry) => {
4947                                    if entry.is_private().await {
4948                                        // BEP 27: private torrents must not use LSD
4949                                        Err(crate::Error::InvalidSettings(
4950                                            "LSD disabled for private torrent".into(),
4951                                        ))
4952                                    } else {
4953                                        if let Some(ref lsd) = self.lsd {
4954                                            lsd.announce(vec![info_hash]).await;
4955                                        }
4956                                        Ok(())
4957                                    }
4958                                }
4959                                None => Err(crate::Error::TorrentNotFound(info_hash)),
4960                            };
4961                            let _ = reply.send(result);
4962                        }
4963                        Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4964                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4965                                entry.handle.read_piece(index).await
4966                            } else {
4967                                Err(crate::Error::TorrentNotFound(info_hash))
4968                            };
4969                            let _ = reply.send(result);
4970                        }
4971                        Some(SessionCommand::FlushCache { info_hash, reply }) => {
4972                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4973                                entry.handle.flush_cache().await
4974                            } else {
4975                                Err(crate::Error::TorrentNotFound(info_hash))
4976                            };
4977                            let _ = reply.send(result);
4978                        }
4979                        Some(SessionCommand::IsValid { info_hash, reply }) => {
4980                            let valid = self.torrents.get(&info_hash)
4981                                .is_some_and(|e| e.handle.is_valid());
4982                            let _ = reply.send(valid);
4983                        }
4984                        Some(SessionCommand::ClearError { info_hash, reply }) => {
4985                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4986                                entry.handle.clear_error().await
4987                            } else {
4988                                Err(crate::Error::TorrentNotFound(info_hash))
4989                            };
4990                            let _ = reply.send(result);
4991                        }
4992                        Some(SessionCommand::FileStatus { info_hash, reply }) => {
4993                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
4994                                entry.handle.file_status().await
4995                            } else {
4996                                Err(crate::Error::TorrentNotFound(info_hash))
4997                            };
4998                            let _ = reply.send(result);
4999                        }
5000                        Some(SessionCommand::Flags { info_hash, reply }) => {
5001                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
5002                                entry.handle.flags().await
5003                            } else {
5004                                Err(crate::Error::TorrentNotFound(info_hash))
5005                            };
5006                            let _ = reply.send(result);
5007                        }
5008                        Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
5009                            // M254 (D3): AUTO_MANAGED is session-level — the
5010                            // torrent actor's apply is a no-op for it, so the
5011                            // public flags API silently dropped the bit until
5012                            // now. Intercept it, delegate the remaining bits.
5013                            let mut result = Ok(());
5014                            if flags.contains(crate::types::TorrentFlags::AUTO_MANAGED) {
5015                                result = self.set_auto_managed_inner(info_hash, true);
5016                            }
5017                            let rest = flags - crate::types::TorrentFlags::AUTO_MANAGED;
5018                            if result.is_ok() {
5019                                result = if let Some(entry) = self.torrents.get(&info_hash) {
5020                                    entry.handle.set_flags(rest).await
5021                                } else {
5022                                    Err(crate::Error::TorrentNotFound(info_hash))
5023                                };
5024                            }
5025                            let _ = reply.send(result);
5026                        }
5027                        Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
5028                            // M254 (D3): mirror of SetFlags interception.
5029                            let mut result = Ok(());
5030                            if flags.contains(crate::types::TorrentFlags::AUTO_MANAGED) {
5031                                result = self.set_auto_managed_inner(info_hash, false);
5032                            }
5033                            let rest = flags - crate::types::TorrentFlags::AUTO_MANAGED;
5034                            if result.is_ok() {
5035                                result = if let Some(entry) = self.torrents.get(&info_hash) {
5036                                    entry.handle.unset_flags(rest).await
5037                                } else {
5038                                    Err(crate::Error::TorrentNotFound(info_hash))
5039                                };
5040                            }
5041                            let _ = reply.send(result);
5042                        }
5043                        Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
5044                            let result = if let Some(entry) = self.torrents.get(&info_hash) {
5045                                entry.handle.connect_peer(addr).await
5046                            } else {
5047                                Err(crate::Error::TorrentNotFound(info_hash))
5048                            };
5049                            let _ = reply.send(result);
5050                        }
5051                        Some(SessionCommand::DhtPutImmutable { value, reply }) => {
5052                            let result = self.handle_dht_put_immutable(value).await;
5053                            let _ = reply.send(result);
5054                        }
5055                        Some(SessionCommand::DhtGetImmutable { target, reply }) => {
5056                            let result = self.handle_dht_get_immutable(target).await;
5057                            let _ = reply.send(result);
5058                        }
5059                        Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
5060                            let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
5061                            let _ = reply.send(result);
5062                        }
5063                        Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
5064                            let result = self.handle_dht_get_mutable(public_key, salt).await;
5065                            let _ = reply.send(result);
5066                        }
5067                        Some(SessionCommand::PostSessionStats) => {
5068                            self.fire_stats_alert();
5069                        }
5070                        Some(SessionCommand::SaveResumeState { reply }) => {
5071                            // M241 L1: run the save OFF the recv loop. The spawned
5072                            // task owns the reply and answers the caller once the
5073                            // write completes, so a slow disk / many torrents no
5074                            // longer freeze the whole session. It waits on
5075                            // resume_save_lock (lock_owned) so it never races an
5076                            // in-flight periodic save onto the same temp path (F4);
5077                            // unlike the periodic tick it always eventually persists,
5078                            // honouring the caller's explicit "save now" intent.
5079                            let lock = Arc::clone(&self.resume_save_lock);
5080                            let (resume_dir, jobs) = self.snapshot_resume_jobs();
5081                            tokio::spawn(async move {
5082                                let _guard = lock.lock_owned().await;
5083                                let count = run_resume_save_jobs(resume_dir, jobs).await;
5084                                let _ = reply.send(Ok(count));
5085                            });
5086                        }
5087                        Some(SessionCommand::AddTorrentM170 { params, reply }) => {
5088                            // M223 — bytes branch goes through the
5089                            // spawn-per-add path; magnet branch stays inline.
5090                            // The dispatcher consumes `reply` directly.
5091                            self.dispatch_add_torrent_m170(*params, reply).await;
5092                        }
5093                        Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
5094                            let result = self.handle_create_category(name, save_path).await;
5095                            let _ = reply.send(result);
5096                        }
5097                        Some(SessionCommand::EditCategory { name, save_path, reply }) => {
5098                            let result = self.handle_edit_category(name, save_path).await;
5099                            let _ = reply.send(result);
5100                        }
5101                        Some(SessionCommand::RemoveCategories { names, reply }) => {
5102                            let result = self.handle_remove_categories(names).await;
5103                            let _ = reply.send(result);
5104                        }
5105                        Some(SessionCommand::ListCategories { reply }) => {
5106                            let snapshot = self.category_registry.read().list();
5107                            let _ = reply.send(snapshot);
5108                        }
5109                        Some(SessionCommand::CreateTags { names, reply }) => {
5110                            let results: Vec<_> = {
5111                                let mut reg = self.tag_registry.write();
5112                                names.into_iter().map(|n| reg.create(n)).collect()
5113                            };
5114                            // Persist any successful creates. Persistence failures
5115                            // warn but don't change the per-call reply (matches
5116                            // CreateCategory).
5117                            if let Err(e) = self.persist_tag_registry().await {
5118                                tracing::warn!(
5119                                    error = %e,
5120                                    "failed to persist tag registry after CreateTags"
5121                                );
5122                            }
5123                            let _ = reply.send(results);
5124                        }
5125                        Some(SessionCommand::DeleteTags { names, reply }) => {
5126                            let removed = self.handle_delete_tags(names).await;
5127                            let _ = reply.send(removed);
5128                        }
5129                        Some(SessionCommand::ListTags { reply }) => {
5130                            let names = self.tag_registry.read().list();
5131                            let _ = reply.send(names);
5132                        }
5133                        Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
5134                            let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
5135                            let _ = reply.send(res);
5136                        }
5137                        Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
5138                            let res = self
5139                                .handle_remove_tags_from_torrents(info_hashes, tags)
5140                                .await;
5141                            let _ = reply.send(res);
5142                        }
5143                        Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
5144                            let result = self.handle_remove_torrent_with_files(info_hash).await;
5145                            let _ = reply.send(result);
5146                        }
5147                        Some(SessionCommand::DebugState { reply }) => {
5148                            let state = self.make_debug_state().await;
5149                            let _ = reply.send(state);
5150                        }
5151                        Some(SessionCommand::Shutdown) | None => {
5152                            self.shutdown_all().await;
5153                            return;
5154                        }
5155                    }
5156                    // M221.1a — emit per-command timing on the
5157                    // bench-harness target. Filtering happens in the
5158                    // tracing subscriber (parallel-7 harness sets
5159                    // RUST_LOG=irontide_session::cmd_timing=info); the
5160                    // emit is unconditional so any external consumer
5161                    // can opt in.
5162                    let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
5163                    info!(
5164                        target: "irontide_session::cmd_timing",
5165                        cmd = cmd_name,
5166                        queue_wait_ms = queue_wait_ms,
5167                        handler_ms = handler_ms,
5168                        "session_cmd"
5169                    );
5170                }
5171                result = async {
5172                    match &mut self.lsd_peers_rx {
5173                        Some(rx) => rx.recv().await,
5174                        None => std::future::pending().await,
5175                    }
5176                } => {
5177                    if let Some((info_hash, peer_addr)) = result
5178                        && let Some(entry) = self.torrents.get(&info_hash)
5179                    {
5180                        // v0.173.1: is_private is async — can't chain it into the let-else
5181                        // condition list, evaluate separately before add_peers.
5182                        let is_priv = entry.is_private().await;
5183                        if !is_priv {
5184                            // BEP 27: reject LSD peers for private torrents
5185                            let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
5186                        }
5187                    }
5188                }
5189                // Pre-validated inbound connections from ListenerTask (M114)
5190                Some(conn) = self.validated_conn_rx.recv() => {
5191                    self.handle_identified_inbound(conn);
5192                }
5193                // SSL inbound connections (M42)
5194                result = async {
5195                    if let Some(ref mut listener) = self.ssl_listener {
5196                        listener.accept().await
5197                    } else {
5198                        std::future::pending().await
5199                    }
5200                } => {
5201                    if let Ok((stream, addr)) = result {
5202                        self.handle_ssl_incoming(stream, addr).await;
5203                    }
5204                }
5205                // Global rate limiter refill (100ms)
5206                _ = refill_interval.tick() => {
5207                    let elapsed = std::time::Duration::from_millis(100);
5208                    self.global_upload_bucket.lock().refill(elapsed);
5209                    self.global_download_bucket.lock().refill(elapsed);
5210                }
5211                // Auto-manage queue evaluation
5212                _ = auto_manage_interval.tick() => {
5213                    self.evaluate_queue().await;
5214                }
5215                // Checking-complete trigger: when a torrent exits Checking
5216                // state, re-evaluate immediately so the next candidate
5217                // promotes without waiting for the 30s periodic tick.
5218                alert = self.self_alert_rx.recv() => {
5219                    if let Ok(alert) = alert
5220                        && matches!(
5221                            alert.kind,
5222                            AlertKind::StateChanged {
5223                                prev_state: TorrentState::Checking,
5224                                new_state,
5225                                ..
5226                            } if new_state != TorrentState::Checking
5227                        )
5228                    {
5229                        self.evaluate_queue().await;
5230                    }
5231                }
5232                // NAT port mapping events
5233                event = recv_nat_event(&mut self.nat_events_rx) => {
5234                    match event {
5235                        irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
5236                            info!(port, %protocol, "port mapping succeeded");
5237                            if protocol == "TCP" {
5238                                // M251/ER3: remember the mapped TCP port for
5239                                // SessionStats::external_address (sticky — see field doc).
5240                                self.external_tcp_port = Some(port);
5241                            }
5242                            post_alert(
5243                                &self.alert_tx,
5244                                &self.alert_mask,
5245                                AlertKind::PortMappingSucceeded { port, protocol },
5246                            );
5247                        }
5248                        irontide_nat::NatEvent::MappingFailed { port, message } => {
5249                            warn!(port, %message, "port mapping failed");
5250                            post_alert(
5251                                &self.alert_tx,
5252                                &self.alert_mask,
5253                                AlertKind::PortMappingFailed { port, message },
5254                            );
5255                        }
5256                        irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
5257                            info!(%ip, "external IP discovered via NAT traversal");
5258                            self.external_ip = Some(ip);
5259                            // Propagate to all active torrents for BEP 40 peer priority.
5260                            for entry in self.torrents.values() {
5261                                let _ = entry.handle.update_external_ip(ip).await;
5262                            }
5263                            // BEP 42: notify DHT instances of external IP
5264                            if let Some(dht) = &self.dht_v4 {
5265                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5266                            }
5267                            if let Some(dht) = &self.dht_v6 {
5268                                let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5269                            }
5270                        }
5271                    }
5272                }
5273                // BEP 42: DHT v4 external IP consensus
5274                Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
5275                    info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
5276                    self.external_ip = Some(ip);
5277                    for entry in self.torrents.values() {
5278                        let _ = entry.handle.update_external_ip(ip).await;
5279                    }
5280                }
5281                // BEP 42: DHT v6 external IP consensus
5282                Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5283                    info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5284                    self.external_ip = Some(ip);
5285                    for entry in self.torrents.values() {
5286                        let _ = entry.handle.update_external_ip(ip).await;
5287                    }
5288                }
5289                // Periodic session stats (M50)
5290                _ = async {
5291                    match &mut stats_timer {
5292                        Some(t) => t.tick().await,
5293                        None => std::future::pending().await,
5294                    }
5295                } => {
5296                    // M245 A1 (phase-4 grounding fix): refresh the published
5297                    // read-model on the SAME periodic cadence that fires the
5298                    // stats alert. The ratified plan assumed `make_session_stats`
5299                    // ran on this tick, but it only ran on-demand via
5300                    // `SessionCommand::SessionStats`. Without this, a client that
5301                    // polls `list_torrent_summaries` (P1) but not `session_stats`
5302                    // would see the snapshot's sampled fields (state, rates,
5303                    // progress) frozen at each torrent's add-time eager publish —
5304                    // membership stayed correct, but state never advanced. Reuse
5305                    // the single bounded fan-out (C1) for its snapshot side
5306                    // effect; the alert below stays counter-based, so the summed
5307                    // result is intentionally discarded.
5308                    let _ = self.make_session_stats().await;
5309                    self.fire_stats_alert();
5310                }
5311                // Periodic sample_infohashes (BEP 51, M111)
5312                _ = async {
5313                    match &mut sample_timer {
5314                        Some(t) => t.tick().await,
5315                        None => std::future::pending().await,
5316                    }
5317                } => {
5318                    self.fire_sample_infohashes().await;
5319                }
5320                // Periodic resume file save (M161) — runs OFF the recv loop (M241 L1).
5321                _ = async {
5322                    match &mut resume_save_interval {
5323                        Some(t) => t.tick().await,
5324                        None => std::future::pending().await,
5325                    }
5326                } => {
5327                    // try_lock_owned: if a save is already in flight (this tick's
5328                    // predecessor on a slow disk, or a SaveResumeState RPC), skip —
5329                    // don't pile up concurrent writers onto the same temp path (F4).
5330                    match Arc::clone(&self.resume_save_lock).try_lock_owned() {
5331                        Ok(guard) => {
5332                            let (resume_dir, jobs) = self.snapshot_resume_jobs();
5333                            tokio::spawn(async move {
5334                                let _guard = guard;
5335                                let count = run_resume_save_jobs(resume_dir, jobs).await;
5336                                if count > 0 {
5337                                    info!(count, "periodic resume save completed");
5338                                }
5339                            });
5340                        }
5341                        Err(_) => {
5342                            debug!("resume save already in flight — skipping this periodic tick");
5343                        }
5344                    }
5345                }
5346                // Periodic resume save interval rebuild (M225)
5347                () = self.resume_save_notify.notified() => {
5348                    resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5349                        Some(tokio::time::interval(std::time::Duration::from_secs(
5350                            self.settings.save_resume_interval_secs,
5351                        )))
5352                    } else {
5353                        None
5354                    };
5355                    if let Some(ref mut t) = resume_save_interval {
5356                        t.tick().await; // skip first immediate tick
5357                    }
5358                }
5359            }
5360        }
5361    }
5362
5363    /// Return clones of global buckets if they have a non-zero rate, else None.
5364    fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5365        let up = if self.settings.upload_rate_limit > 0 {
5366            Some(Arc::clone(&self.global_upload_bucket))
5367        } else {
5368            None
5369        };
5370        let down = if self.settings.download_rate_limit > 0 {
5371            Some(Arc::clone(&self.global_download_bucket))
5372        } else {
5373            None
5374        };
5375        (up, down)
5376    }
5377
5378    fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5379        if self.settings.auto_upload_slots {
5380            crate::slot_tuner::SlotTuner::new(
5381                4, // initial slots
5382                self.settings.auto_upload_slots_min,
5383                self.settings.auto_upload_slots_max,
5384            )
5385        } else {
5386            crate::slot_tuner::SlotTuner::disabled(4)
5387        }
5388    }
5389
5390    fn make_torrent_config(&self) -> TorrentConfig {
5391        TorrentConfig::from(&self.settings)
5392    }
5393
5394    /// Returns the next available queue position (one past the max).
5395    fn next_queue_position(&self) -> i32 {
5396        self.torrents
5397            .values()
5398            .filter(|e| e.auto_managed)
5399            .map(|e| e.queue_position)
5400            .max()
5401            .map_or(0, |m| m + 1)
5402    }
5403
5404    /// Inline add-torrent path: builds the prep bundle on the actor,
5405    /// runs prepare + commit phases sequentially without spawning. Used
5406    /// by the resume-restore startup path (single-threaded by
5407    /// construction) and the `handle_add_torrent_with_params` M170
5408    /// helper. The `AddTorrent` and `AddTorrentM170` recv arms call
5409    /// `try_spawn_add_torrent` instead — same prep + commit primitives,
5410    /// but the prep runs in a `tokio::spawn` and the commit hop returns
5411    /// via `SessionCommand::CommitAddTorrent` (M223).
5412    async fn handle_add_torrent(
5413        &mut self,
5414        torrent_meta: irontide_core::TorrentMeta,
5415        storage: Option<Arc<dyn TorrentStorage>>,
5416        download_dir: Option<PathBuf>,
5417        tags: Vec<String>,
5418        overrides: AddConfigOverrides,
5419    ) -> crate::Result<Id20> {
5420        let info_hash = torrent_meta
5421            .as_v1()
5422            .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5423        if self.torrents.contains_key(&info_hash) {
5424            return Err(crate::Error::DuplicateTorrent(info_hash));
5425        }
5426        if self.torrents.len() >= self.settings.max_torrents {
5427            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5428        }
5429        let bundle = self.build_add_torrent_prep_bundle(
5430            torrent_meta,
5431            storage,
5432            download_dir,
5433            tags,
5434            &overrides,
5435            None,
5436        );
5437        let prep = prepare_add_torrent_off_actor(bundle).await;
5438        self.commit_add_torrent(prep).await
5439    }
5440
5441    /// M223 — build the off-actor add-torrent prep bundle synchronously
5442    /// from session state. Cheap Arc-clones + a single
5443    /// `make_torrent_config` snapshot; never awaits, so it stays on the
5444    /// actor without contributing to the queue-wait stack.
5445    fn build_add_torrent_prep_bundle(
5446        &self,
5447        torrent_meta: irontide_core::TorrentMeta,
5448        storage: Option<Arc<dyn TorrentStorage>>,
5449        download_dir: Option<PathBuf>,
5450        tags: Vec<String>,
5451        overrides: &AddConfigOverrides,
5452        m170_post: Option<M170PostAdd>,
5453    ) -> AddTorrentPrepBundle {
5454        let mut torrent_config = self.make_torrent_config();
5455        if let Some(dir) = download_dir {
5456            torrent_config.download_dir = dir;
5457        }
5458        // M171: bake tags into the config BEFORE the actor is constructed so
5459        // the first `stats()` snapshot already carries them — no post-add
5460        // spawn race. An empty vec is a no-op but still replaces any
5461        // session-level default (currently also empty).
5462        torrent_config.tags = tags;
5463        // M252/M253: explicit per-add overrides beat the Settings-derived
5464        // values `make_torrent_config` baked in via `From<&Settings>`.
5465        overrides.bake_into(&mut torrent_config);
5466
5467        let (global_up, global_down) = self.global_buckets_if_limited();
5468        let slot_tuner = self.make_slot_tuner();
5469
5470        AddTorrentPrepBundle {
5471            torrent_meta,
5472            storage_override: storage,
5473            torrent_config,
5474            disk_manager: self.disk_manager.clone(),
5475            dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5476            dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5477            global_up,
5478            global_down,
5479            slot_tuner,
5480            alert_tx: self.alert_tx.clone(),
5481            alert_mask: Arc::clone(&self.alert_mask),
5482            utp_socket: self.utp_socket.clone(),
5483            utp_socket_v6: self.utp_socket_v6.clone(),
5484            ban_manager: Arc::clone(&self.ban_manager),
5485            ip_filter: Arc::clone(&self.ip_filter),
5486            plugins: Arc::clone(&self.plugins),
5487            sam_session: self.sam_session.clone(),
5488            ssl_manager: self.ssl_manager.clone(),
5489            factory: Arc::clone(&self.factory),
5490            hash_pool: Arc::clone(&self.hash_pool),
5491            counters: Arc::clone(&self.counters),
5492            m170_post,
5493            auto_managed: overrides.auto_managed,
5494        }
5495    }
5496
5497    /// M223 — commit phase of the spawn-per-add fix. Mutates session
5498    /// state (insert into `self.torrents`, info-hash registry, queue
5499    /// position, alert, LSD announce, M170 post-hooks) using the
5500    /// success-path payload from `prepare_add_torrent_off_actor`. On
5501    /// `Err` from prep, returns the error untouched.
5502    async fn commit_add_torrent(
5503        &mut self,
5504        prep: crate::Result<PreparedAddTorrent>,
5505    ) -> crate::Result<Id20> {
5506        let PreparedAddTorrent {
5507            handle,
5508            info_hash,
5509            is_private,
5510            m170_post,
5511            auto_managed,
5512        } = prep?;
5513        // Re-check dup + capacity: parallel adds may both pass the
5514        // pre-spawn check, then race the commit. The later one fails
5515        // here, and dropping `handle` triggers the spawned TorrentActor's
5516        // graceful shutdown (its cmd_tx receiver hangs up → recv returns
5517        // None → actor exits).
5518        if self.torrents.contains_key(&info_hash) {
5519            drop(handle);
5520            return Err(crate::Error::DuplicateTorrent(info_hash));
5521        }
5522        if self.torrents.len() >= self.settings.max_torrents {
5523            drop(handle);
5524            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5525        }
5526        self.torrents.insert(
5527            info_hash,
5528            TorrentEntry {
5529                handle,
5530                queue_position: -1,
5531                // M254 (D2): construction-time truth — `false` never holds
5532                // a queue slot, no post-insert churn.
5533                auto_managed: auto_managed.unwrap_or(true),
5534                started_at: Some(tokio::time::Instant::now()),
5535                smoothed_download_rate: f64::MAX,
5536                smoothed_upload_rate: f64::MAX,
5537            },
5538        );
5539        self.info_hash_registry.insert(info_hash, ());
5540
5541        // Assign queue position for auto-managed torrents
5542        let pos = self.next_queue_position();
5543        if let Some(entry) = self.torrents.get_mut(&info_hash)
5544            && entry.auto_managed
5545        {
5546            entry.queue_position = pos;
5547        }
5548
5549        info!(%info_hash, "torrent added to session");
5550        // M223 — `TorrentAdded` is posted in `prepare_add_torrent_off_actor`
5551        // (BEFORE the `commit_tx.send.await` yield) to preserve the alert
5552        // ordering invariant under spawn-per-add. See the comment there.
5553        // BEP 27: private torrents must not use LSD. `is_private` is
5554        // precomputed from `meta.info.private == Some(1)` in the prep
5555        // bundle, so the commit arm needs no async query.
5556        if let Some(ref lsd) = self.lsd
5557            && !is_private
5558        {
5559            lsd.announce(vec![info_hash]).await;
5560        }
5561        // M170 post-add hooks (category + paused-on-add) for the
5562        // `AddTorrentM170` path. Always `None` for the legacy
5563        // `AddTorrent` path.
5564        if let Some(M170PostAdd { category, paused }) = m170_post {
5565            self.apply_post_add_m170(info_hash, category, paused);
5566        }
5567        // M245 A1 — eager membership publish. This is the SOLE non-magnet
5568        // insert site: it covers spawn-per-add commits AND startup restore
5569        // (`handle_load_resume_state` → `handle_add_torrent` → here), so reads
5570        // see the torrent the instant its add commits.
5571        self.snapshot_publish_one(info_hash).await;
5572        Ok(info_hash)
5573    }
5574
5575    /// M223 — recv-arm helper: spawn the prep phase off the actor,
5576    /// route the result back via `CommitAddTorrent`. Consumes `reply`,
5577    /// so callers must not also send to it.
5578    fn try_spawn_add_torrent(
5579        &self,
5580        bundle: AddTorrentPrepBundle,
5581        reply: oneshot::Sender<crate::Result<Id20>>,
5582    ) {
5583        let commit_tx = self.commit_tx.clone();
5584        tokio::spawn(async move {
5585            let result = prepare_add_torrent_off_actor(bundle).await;
5586            if commit_tx
5587                .send(SessionCommand::CommitAddTorrent { result, reply })
5588                .await
5589                .is_err()
5590            {
5591                // Session is shutting down; the receiver is gone too.
5592                // The `reply` oneshot was moved into the variant which
5593                // got dropped, so the original caller observes a closed
5594                // channel — the right shutdown signal.
5595                warn!("M223 prep task: commit_tx send failed (session shutting down)");
5596            }
5597        });
5598    }
5599
5600    async fn handle_add_magnet(
5601        &mut self,
5602        magnet: Magnet,
5603        download_dir: Option<PathBuf>,
5604        tags: Vec<String>,
5605        overrides: AddConfigOverrides,
5606    ) -> crate::Result<Id20> {
5607        let info_hash = magnet.info_hash();
5608        let display_name = magnet.display_name.clone().unwrap_or_default();
5609        if self.torrents.contains_key(&info_hash) {
5610            return Err(crate::Error::DuplicateTorrent(info_hash));
5611        }
5612        if self.torrents.len() >= self.settings.max_torrents {
5613            return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5614        }
5615        let mut config = self.make_torrent_config();
5616        if let Some(dir) = download_dir {
5617            config.download_dir = dir;
5618        }
5619        // M171: bake tags into the config BEFORE the actor is constructed so
5620        // the first `stats()` snapshot already carries them — no post-add
5621        // spawn race. An empty vec is a no-op but still replaces any
5622        // session-level default (currently also empty).
5623        config.tags = tags;
5624        // M252/M253: same override-beats-derivation rule as the bytes path.
5625        overrides.bake_into(&mut config);
5626        let (global_up, global_down) = self.global_buckets_if_limited();
5627        let slot_tuner = self.make_slot_tuner();
5628        let handle = TorrentHandle::from_magnet(
5629            magnet,
5630            self.disk_manager.clone(),
5631            config,
5632            self.dht_v4_broadcast.subscribe(),
5633            self.dht_v6_broadcast.subscribe(),
5634            global_up,
5635            global_down,
5636            slot_tuner,
5637            self.alert_tx.clone(),
5638            Arc::clone(&self.alert_mask),
5639            self.utp_socket.clone(),
5640            self.utp_socket_v6.clone(),
5641            Arc::clone(&self.ban_manager),
5642            Arc::clone(&self.ip_filter),
5643            Arc::clone(&self.plugins),
5644            self.sam_session.clone(),
5645            self.ssl_manager.clone(),
5646            Arc::clone(&self.factory),
5647            Some(Arc::clone(&self.hash_pool)),
5648            Arc::clone(&self.counters),
5649        )
5650        .await?;
5651        // M147: Spawn background metadata resolver before registering.
5652        // This races against the TorrentActor's own FetchingMetadata phase —
5653        // first to resolve wins.
5654        self.spawn_metadata_resolver(info_hash, &handle);
5655
5656        self.torrents.insert(
5657            info_hash,
5658            TorrentEntry {
5659                handle,
5660                queue_position: -1,
5661                // M254 (D2): construction-time truth — `false` never holds
5662                // a queue slot, no post-insert churn.
5663                auto_managed: overrides.auto_managed.unwrap_or(true),
5664                started_at: Some(tokio::time::Instant::now()),
5665                smoothed_download_rate: f64::MAX,
5666                smoothed_upload_rate: f64::MAX,
5667            },
5668        );
5669        self.info_hash_registry.insert(info_hash, ());
5670
5671        // Assign queue position for auto-managed torrents
5672        let pos = self.next_queue_position();
5673        if let Some(entry) = self.torrents.get_mut(&info_hash)
5674            && entry.auto_managed
5675        {
5676            entry.queue_position = pos;
5677        }
5678
5679        info!(%info_hash, "magnet torrent added to session");
5680        post_alert(
5681            &self.alert_tx,
5682            &self.alert_mask,
5683            AlertKind::TorrentAdded {
5684                info_hash,
5685                name: display_name,
5686            },
5687        );
5688        // BEP 27: magnet metadata not available yet — we allow this one-time LAN
5689        // announce. Once metadata resolves, all subsequent LSD ops are gated by
5690        // is_private() checks in ForceLsdAnnounce and lsd_peers_rx handlers.
5691        if let Some(ref lsd) = self.lsd {
5692            lsd.announce(vec![info_hash]).await;
5693        }
5694        // M245 A1 — eager membership publish (magnet add site). At this point
5695        // metadata is unresolved, so the published summary carries the magnet's
5696        // display name + zeroed size/rates; the tick refreshes it once the
5697        // actor reports real stats.
5698        self.snapshot_publish_one(info_hash).await;
5699        Ok(info_hash)
5700    }
5701
5702    /// M147: Spawn a background task that pre-resolves magnet metadata via DHT.
5703    ///
5704    /// The resolver connects to peers discovered via DHT `get_peers`, performs
5705    /// BT + BEP 10 extension + BEP 9 `ut_metadata` exchanges, and sends the
5706    /// assembled metadata back to the `TorrentActor` via `PreResolvedMetadata`.
5707    /// This races against the `TorrentActor`'s own `FetchingMetadata` phase.
5708    fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5709        let dht = match self.dht_v4 {
5710            Some(ref dht) => dht.clone(),
5711            None => return, // No DHT = skip background resolution
5712        };
5713        let factory = Arc::clone(&self.factory);
5714        let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5715        let handle = torrent_handle.clone();
5716
5717        tokio::spawn(async move {
5718            let peer_rx = match dht.get_peers(info_hash).await {
5719                Ok(rx) => rx,
5720                Err(e) => {
5721                    debug!(
5722                        %info_hash,
5723                        "metadata resolver: failed to start DHT get_peers: {e}"
5724                    );
5725                    return;
5726                }
5727            };
5728
5729            let peer_id = irontide_core::PeerId::generate().0;
5730            match crate::metadata_resolver::resolve_metadata(
5731                info_hash,
5732                peer_id,
5733                peer_rx,
5734                factory,
5735                connect_timeout,
5736                crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5737            )
5738            .await
5739            {
5740                Ok((meta, peers)) => {
5741                    let info_bytes = if let Some(b) = meta.info_bytes {
5742                        b.to_vec()
5743                    } else {
5744                        match irontide_bencode::to_bytes(&meta.info) {
5745                            Ok(bytes) => bytes,
5746                            Err(e) => {
5747                                debug!(
5748                                    %info_hash,
5749                                    "metadata resolver: failed to re-encode info dict: {e}"
5750                                );
5751                                return;
5752                            }
5753                        }
5754                    };
5755                    debug!(
5756                        %info_hash,
5757                        num_peers = peers.len(),
5758                        "metadata resolver: pre-resolved metadata, sending to torrent actor"
5759                    );
5760                    handle.send_pre_resolved_metadata(info_bytes, peers);
5761                }
5762                Err(e) => {
5763                    debug!(
5764                        %info_hash,
5765                        "metadata resolver: failed to resolve metadata: {e}"
5766                    );
5767                }
5768            }
5769        });
5770    }
5771
5772    async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5773        let entry = self
5774            .torrents
5775            .remove(&info_hash)
5776            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5777        self.info_hash_registry.remove(&info_hash);
5778        // M245 A1 — eager membership drop. This is the SOLE remove site:
5779        // `handle_remove_torrent_with_files` delegates here, so reads stop
5780        // seeing the torrent the instant its removal commits.
5781        self.snapshot_drop_one(info_hash);
5782        let was_auto_managed = entry.auto_managed;
5783        let removed_position = entry.queue_position;
5784        entry.handle.shutdown().await?;
5785        self.disk_manager.unregister_torrent(info_hash).await;
5786
5787        // Shift queue positions for remaining auto-managed torrents
5788        if was_auto_managed && removed_position >= 0 {
5789            let mut entries = self.queue_entries();
5790            let changed = crate::queue::remove_position(&mut entries, removed_position);
5791            self.apply_queue_changes(&changed);
5792        }
5793
5794        // Delete the resume file for this torrent so it is not restored
5795        // on the next startup. Errors are logged but not propagated — the
5796        // torrent is already removed from the in-memory state.
5797        let resume_dir = self.effective_resume_dir();
5798        if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5799            // NotFound is expected when no resume file was ever written.
5800            if e.kind() != std::io::ErrorKind::NotFound {
5801                warn!(%info_hash, "failed to delete resume file on removal: {e}");
5802            }
5803        }
5804
5805        info!(%info_hash, "torrent removed from session");
5806        post_alert(
5807            &self.alert_tx,
5808            &self.alert_mask,
5809            AlertKind::TorrentRemoved { info_hash },
5810        );
5811        Ok(())
5812    }
5813
5814    // ── M170 handlers ──────────────────────────────────────────────────
5815
5816    /// M170: unified add entry. Parses magnet or bytes, resolves the
5817    /// download directory via category precedence, and dispatches to the
5818    /// existing add paths.
5819    /// M223 — dispatcher for the `AddTorrentM170` recv arm. The bytes
5820    /// branch goes through the spawn-per-add path
5821    /// (`try_spawn_add_torrent`); the magnet branch stays inline because
5822    /// it is dominated by metadata fetch latency, not by
5823    /// `TorrentHandle::from_torrent` cost, and is out of M223 scope.
5824    /// Consumes `reply` directly — either sends an error synchronously
5825    /// or hands `reply` to the spawned prep task (which routes it back
5826    /// via `CommitAddTorrent`).
5827    async fn dispatch_add_torrent_m170(
5828        &mut self,
5829        params: AddTorrentParams,
5830        reply: oneshot::Sender<crate::Result<Id20>>,
5831    ) {
5832        // Resolve download_dir + prepare category label early so that
5833        // an unknown category fails fast (before any parsing work).
5834        let (resolved_dir, resolved_category) =
5835            match self.resolve_download_dir_and_category(&params) {
5836                Ok(x) => x,
5837                Err(e) => {
5838                    let _ = reply.send(Err(e));
5839                    return;
5840                }
5841            };
5842
5843        let AddTorrentParams {
5844            source,
5845            tags,
5846            paused,
5847            skip_checking: _, // reserved for M171+
5848            content_layout,
5849            preallocate_mode,
5850            auto_managed,
5851            sequential_download,
5852            prioritize_first_last_pieces,
5853            file_priorities,
5854            ..
5855        } = params;
5856        // M254: all per-add knobs ride the one threading struct (D1).
5857        let overrides = AddConfigOverrides {
5858            content_layout,
5859            sequential_download,
5860            prioritize_first_last_pieces,
5861            preallocate_mode,
5862            file_priorities,
5863            auto_managed,
5864        };
5865
5866        // M226: resolve None → engine `default_add_paused`. `Some(v)` is an
5867        // explicit per-call override and wins over the engine setting.
5868        let paused = paused.unwrap_or(self.settings.default_add_paused);
5869
5870        match source {
5871            AddSource::Magnet(uri) => {
5872                // Magnet path: stays inline (out of M223 scope).
5873                let result: crate::Result<Id20> = async {
5874                    let magnet = irontide_core::Magnet::parse(&uri)?;
5875                    let info_hash = magnet.info_hash();
5876                    self.reject_if_in_deletion_grace(info_hash)?;
5877                    let id = self
5878                        .handle_add_magnet(magnet, resolved_dir, tags, overrides)
5879                        .await?;
5880                    self.apply_post_add_m170(id, resolved_category, paused);
5881                    Ok(id)
5882                }
5883                .await;
5884                let _ = reply.send(result);
5885            }
5886            AddSource::Bytes(bytes) => {
5887                // Bytes path: spawn-per-add.
5888                let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5889                    let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5890                    let info_hash = meta
5891                        .as_v1()
5892                        .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5893                    self.reject_if_in_deletion_grace(info_hash)?;
5894                    if self.torrents.contains_key(&info_hash) {
5895                        return Err(crate::Error::DuplicateTorrent(info_hash));
5896                    }
5897                    if self.torrents.len() >= self.settings.max_torrents {
5898                        return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5899                    }
5900                    Ok(self.build_add_torrent_prep_bundle(
5901                        meta,
5902                        None,
5903                        resolved_dir,
5904                        tags,
5905                        &overrides,
5906                        Some(M170PostAdd {
5907                            category: resolved_category,
5908                            paused,
5909                        }),
5910                    ))
5911                })();
5912                match setup {
5913                    Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5914                    Err(e) => {
5915                        let _ = reply.send(Err(e));
5916                    }
5917                }
5918            }
5919        }
5920    }
5921
5922    /// Resolve the effective download directory + the category label to
5923    /// store on the `TorrentConfig`, following the M170 precedence rules.
5924    fn resolve_download_dir_and_category(
5925        &self,
5926        params: &AddTorrentParams,
5927    ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5928        match (&params.download_dir, &params.category) {
5929            (Some(explicit), cat) => {
5930                // Explicit path wins even when a category is set — qBt
5931                // preserves the category label either way.
5932                Ok((Some(explicit.clone()), cat.clone()))
5933            }
5934            (None, Some(name)) => {
5935                let registry = self.category_registry.read();
5936                match registry.get(name) {
5937                    Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5938                    None => Err(crate::Error::CategoryNotFound(name.clone())),
5939                }
5940            }
5941            (None, None) => Ok((None, None)),
5942        }
5943    }
5944
5945    /// Return an error if `info_hash` is currently being removed by a
5946    /// deleteFiles=true call in another task.
5947    fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5948        if self.deletion_grace.lock().contains(&info_hash) {
5949            return Err(crate::Error::TorrentBeingRemoved(info_hash));
5950        }
5951        Ok(())
5952    }
5953
5954    /// Post-add M170 hooks: stash the category label and mark the torrent
5955    /// paused if requested. Category is recorded for the initial stats
5956    /// snapshot; paused-on-add uses the existing pause path.
5957    fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5958        if let Some(entry) = self.torrents.get(&info_hash) {
5959            // Category label: stored on the torrent handle's config mirror
5960            // so future `stats()` calls include it. Fire-and-forget
5961            // because the field is non-load-bearing for the add path.
5962            if let Some(name) = category {
5963                let handle = entry.handle.clone();
5964                tokio::spawn(async move {
5965                    if let Err(e) = handle.set_category(Some(name)).await {
5966                        warn!(%info_hash, "failed to propagate category: {e}");
5967                    }
5968                });
5969            }
5970            if paused {
5971                let handle = entry.handle.clone();
5972                tokio::spawn(async move {
5973                    if let Err(e) = handle.pause().await {
5974                        warn!(%info_hash, "failed to pause on add: {e}");
5975                    }
5976                });
5977            }
5978        }
5979    }
5980
5981    /// M170: create a category, persist the registry on success.
5982    async fn handle_create_category(
5983        &self,
5984        name: String,
5985        save_path: PathBuf,
5986    ) -> Result<(), crate::category_manager::CategoryError> {
5987        {
5988            let mut registry = self.category_registry.write();
5989            registry.create(name, save_path)?;
5990        }
5991        self.persist_category_registry().await
5992    }
5993
5994    /// M170: edit a category, persist the registry on success.
5995    async fn handle_edit_category(
5996        &self,
5997        name: String,
5998        save_path: PathBuf,
5999    ) -> Result<(), crate::category_manager::CategoryError> {
6000        {
6001            let mut registry = self.category_registry.write();
6002            registry.edit(&name, save_path)?;
6003        }
6004        self.persist_category_registry().await
6005    }
6006
6007    /// M170: remove categories, clear labels on affected torrents, and
6008    /// persist the registry. Returns the names that were actually
6009    /// removed so the CLI / qBt handler can log or echo them.
6010    async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
6011        let removed: Vec<String> = {
6012            let mut registry = self.category_registry.write();
6013            registry.remove(&names)
6014        };
6015        if removed.is_empty() {
6016            return removed;
6017        }
6018
6019        // Clear the `category` label on every torrent assigned to a
6020        // removed name. Fire-and-forget per torrent — failure only
6021        // costs us label-sync (not data).
6022        for entry in self.torrents.values() {
6023            let handle = entry.handle.clone();
6024            let to_check: Vec<String> = removed.clone();
6025            tokio::spawn(async move {
6026                if let Ok(stats) = handle.stats().await
6027                    && let Some(current) = stats.category
6028                    && to_check.iter().any(|n| n.as_str() == current.as_str())
6029                    && let Err(e) = handle.set_category(None).await
6030                {
6031                    warn!(
6032                        cat = %current,
6033                        "failed to clear category label after removeCategories: {e}"
6034                    );
6035                }
6036            });
6037        }
6038
6039        if let Err(e) = self.persist_category_registry().await {
6040            warn!("failed to persist category registry after remove: {e}");
6041        }
6042        removed
6043    }
6044
6045    /// Spawn a blocking task to persist the registry to disk.
6046    async fn persist_category_registry(
6047        &self,
6048    ) -> Result<(), crate::category_manager::CategoryError> {
6049        let registry = Arc::clone(&self.category_registry);
6050        // Clone the current registry state out of the lock to avoid
6051        // holding it across the spawn_blocking boundary.
6052        let snapshot = registry.read().clone();
6053        tokio::task::spawn_blocking(move || snapshot.save())
6054            .await
6055            .map_err(|join_err| {
6056                crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
6057                    "category registry save join error: {join_err}"
6058                )))
6059            })?
6060    }
6061
6062    /// M171: delete a batch of tags. Returns the subset of names that
6063    /// were actually present at call time (unknown names are silently
6064    /// ignored, matching qBt `deleteTags`).
6065    ///
6066    /// After removal, any torrent carrying a deleted tag has that tag
6067    /// pruned from its label set via `TorrentHandle::set_tags`.
6068    async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
6069        let removed = {
6070            let mut reg = self.tag_registry.write();
6071            reg.delete(&names)
6072        };
6073        if !removed.is_empty() {
6074            let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
6075            for entry in self.torrents.values() {
6076                let handle = entry.handle.clone();
6077                let to_remove = to_remove.clone();
6078                tokio::spawn(async move {
6079                    if let Ok(stats) = handle.stats().await {
6080                        let new_tags: Vec<String> = stats
6081                            .tags
6082                            .into_iter()
6083                            .filter(|t| !to_remove.contains(t))
6084                            .collect();
6085                        if let Err(e) = handle.set_tags(new_tags).await {
6086                            tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
6087                        }
6088                    }
6089                });
6090            }
6091            if let Err(e) = self.persist_tag_registry().await {
6092                tracing::warn!(error = %e, "persist tag registry after DeleteTags");
6093            }
6094        }
6095        removed
6096    }
6097
6098    /// M171: add the given tags to each torrent in `info_hashes`. The
6099    /// engine-layer command is a wholesale replacement, so this reads
6100    /// the current tag set for each torrent, unions in the requested
6101    /// tags (sorted + deduped), and replays the result via
6102    /// `TorrentHandle::set_tags`. Unknown info hashes are silently
6103    /// skipped — qBt's `addTags` behaviour.
6104    ///
6105    /// # Errors
6106    ///
6107    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
6108    /// stopped while the batch was in flight.
6109    async fn handle_add_tags_to_torrents(
6110        &self,
6111        info_hashes: Vec<Id20>,
6112        tags_to_add: Vec<String>,
6113    ) -> crate::Result<()> {
6114        for hash in info_hashes {
6115            let Some(entry) = self.torrents.get(&hash) else {
6116                continue;
6117            };
6118            let current = entry.handle.stats().await?;
6119            let mut new_tags = current.tags;
6120            for t in &tags_to_add {
6121                if !new_tags.contains(t) {
6122                    new_tags.push(t.clone());
6123                }
6124            }
6125            new_tags.sort();
6126            new_tags.dedup();
6127            entry.handle.set_tags(new_tags).await?;
6128        }
6129        Ok(())
6130    }
6131
6132    /// M171: remove the given tags from each torrent in `info_hashes`.
6133    /// Unknown info hashes are silently skipped — qBt's `removeTags`
6134    /// behaviour.
6135    ///
6136    /// # Errors
6137    ///
6138    /// Propagates [`crate::Error::Shutdown`] if a torrent actor has
6139    /// stopped while the batch was in flight.
6140    async fn handle_remove_tags_from_torrents(
6141        &self,
6142        info_hashes: Vec<Id20>,
6143        tags_to_remove: Vec<String>,
6144    ) -> crate::Result<()> {
6145        for hash in info_hashes {
6146            let Some(entry) = self.torrents.get(&hash) else {
6147                continue;
6148            };
6149            let current = entry.handle.stats().await?;
6150            let new_tags: Vec<String> = current
6151                .tags
6152                .into_iter()
6153                .filter(|t| !tags_to_remove.contains(t))
6154                .collect();
6155            entry.handle.set_tags(new_tags).await?;
6156        }
6157        Ok(())
6158    }
6159
6160    /// M171: spawn a blocking task to persist the tag registry to disk.
6161    /// Mirrors `persist_category_registry`.
6162    async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
6163        let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
6164        tokio::task::spawn_blocking(move || to_save.save())
6165            .await
6166            .unwrap_or_else(|_| {
6167                Err(crate::tag_manager::TagError::Persistence(
6168                    std::io::Error::other("spawn_blocking failed"),
6169                ))
6170            })
6171    }
6172
6173    /// M170: remove a torrent and delete its files from disk.
6174    async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
6175        // v0.173.1: query the TorrentActor for metadata instead of reading
6176        // the deleted `TorrentEntry.meta` cache. For magnet torrents with
6177        // resolved metadata this returns the real file list (fixing the
6178        // v0.173.0 silent-no-op-on-disk bug). For magnets *still resolving*
6179        // we treat it as an empty file list and still proceed with the
6180        // session-level removal — matches the pre-v0.173.1 observable
6181        // behaviour that the *arr e2e test relies on (`deleteFiles=true` on
6182        // a pre-metadata magnet leaves `download_dir` untouched but cleanly
6183        // removes the torrent from the registry).
6184        let handle = {
6185            let entry = self
6186                .torrents
6187                .get(&info_hash)
6188                .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6189            entry.handle.clone()
6190        };
6191        // M252/ER5: the walk must use the on-disk (laid-out) paths, so it
6192        // needs both the meta AND the torrent's `content_layout` (carried on
6193        // stats). Both-or-nothing: walking logical paths for a torrent stored
6194        // under a different layout would target the wrong (usually
6195        // nonexistent) paths and orphan the real files.
6196        let file_paths: Vec<PathBuf> = match (handle.stats().await, handle.get_meta().await) {
6197            (Ok(stats), Ok(Some(meta))) => stats
6198                .content_layout
6199                .apply_to_files(meta.info.files())
6200                .iter()
6201                .map(|f| f.path.iter().collect::<PathBuf>())
6202                .collect(),
6203            // Meta not yet resolved (pre-metadata magnet), actor shut down,
6204            // or stats unavailable: nothing to walk on disk. Session-level
6205            // removal still proceeds.
6206            _ => Vec::new(),
6207        };
6208        let download_dir = self.settings.download_dir.clone();
6209        let _ = handle.pause().await;
6210
6211        // Enter the deletion grace window BEFORE dropping the in-memory
6212        // entry — any add that races in during the walk will see the
6213        // grace set and get 409'd.
6214        self.deletion_grace.lock().insert(info_hash);
6215
6216        // Remove from session (same as the existing path — closes
6217        // storage handles, deletes resume file, etc.).
6218        let remove_result = self.handle_remove_torrent(info_hash).await;
6219        if let Err(e) = &remove_result {
6220            warn!(
6221                %info_hash,
6222                error = %e,
6223                "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
6224            );
6225        }
6226
6227        // Now blast the files. `download_dir` is the session default (good
6228        // enough for M170 because add_torrent threads the same dir through
6229        // to storage). Once per-torrent save path is recorded (future
6230        // milestone), swap this out.
6231        let grace = Arc::clone(&self.deletion_grace);
6232        tokio::task::spawn_blocking(move || {
6233            irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
6234            grace.lock().remove(&info_hash);
6235        });
6236
6237        Ok(())
6238    }
6239
6240    async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6241        let entry = self
6242            .torrents
6243            .get(&info_hash)
6244            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6245        entry.handle.pause().await
6246    }
6247
6248    async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6249        let entry = self
6250            .torrents
6251            .get(&info_hash)
6252            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6253        entry.handle.resume().await
6254    }
6255
6256    async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
6257        let entry = self
6258            .torrents
6259            .get(&info_hash)
6260            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6261        entry
6262            .handle
6263            .cmd_tx
6264            .send(crate::types::TorrentCommand::ForceResume)
6265            .await
6266            .map_err(|_| crate::Error::Shutdown)
6267    }
6268
6269    async fn handle_set_torrent_seed_ratio(
6270        &self,
6271        info_hash: Id20,
6272        limit: Option<f64>,
6273    ) -> crate::Result<()> {
6274        let entry = self
6275            .torrents
6276            .get(&info_hash)
6277            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6278        let (tx, rx) = oneshot::channel();
6279        entry
6280            .handle
6281            .cmd_tx
6282            .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
6283            .await
6284            .map_err(|_| crate::Error::Shutdown)?;
6285        rx.await.map_err(|_| crate::Error::Shutdown)
6286    }
6287
6288    async fn handle_move_torrent_storage(
6289        &self,
6290        info_hash: Id20,
6291        new_path: std::path::PathBuf,
6292    ) -> crate::Result<()> {
6293        let entry = self
6294            .torrents
6295            .get(&info_hash)
6296            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6297        entry.handle.move_storage(new_path).await
6298    }
6299
6300    async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
6301        let entry = self
6302            .torrents
6303            .get(&info_hash)
6304            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6305        let mut stats = entry.handle.stats().await?;
6306        // Enrich with session-level data that the torrent actor doesn't own.
6307        stats.queue_position = entry.queue_position;
6308        stats.auto_managed = entry.auto_managed;
6309        Ok(stats)
6310    }
6311
6312    async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
6313        // v0.173.1: queries the TorrentActor (single source of truth) instead
6314        // of reading the deleted `TorrentEntry.meta` cache. For magnet torrents
6315        // this returns the real meta once assembled, where v0.173.0 always saw
6316        // `None` → 404 on `/api/v2/torrents/files`.
6317        let meta = self.get_entry_meta(info_hash).await?;
6318        let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
6319            file_list
6320                .iter()
6321                .map(|f| FileInfo {
6322                    path: f.path.iter().collect::<PathBuf>(),
6323                    length: f.length,
6324                })
6325                .collect()
6326        } else {
6327            vec![FileInfo {
6328                path: PathBuf::from(&meta.info.name),
6329                length: meta.info.total_length(),
6330            }]
6331        };
6332
6333        Ok(TorrentInfo {
6334            info_hash,
6335            name: meta.info.name.clone(),
6336            total_length: meta.info.total_length(),
6337            piece_length: meta.info.piece_length,
6338            num_pieces: meta.info.num_pieces() as u32,
6339            files,
6340            private: meta.info.private == Some(1),
6341        })
6342    }
6343
6344    /// Update gauge metrics that come from session-level state.
6345    fn update_session_gauges(&self) {
6346        use crate::stats::{
6347            DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6348            SES_NUM_TORRENTS,
6349        };
6350        let c = &self.counters;
6351        c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6352        c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6353
6354        // DHT presence (instance count, not routing table size)
6355        let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6356        c.set(DHT_NODES, dht_nodes);
6357        c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6358        c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6359
6360        // Ban count
6361        let ban_count = self.ban_manager.read().banned_list().len() as i64;
6362        c.set(PEER_NUM_BANNED, ban_count);
6363    }
6364
6365    /// Snapshot counters and fire a `SessionStatsAlert`.
6366    fn fire_stats_alert(&self) {
6367        self.update_session_gauges();
6368        let values = self.counters.snapshot();
6369        crate::alert::post_alert(
6370            &self.alert_tx,
6371            &self.alert_mask,
6372            crate::alert::AlertKind::SessionStatsAlert { values },
6373        );
6374    }
6375
6376    /// Fire a periodic BEP 51 `sample_infohashes` query to the DHT (M111).
6377    async fn fire_sample_infohashes(&self) {
6378        let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6379            return;
6380        };
6381        let mut buf = [0u8; 20];
6382        irontide_core::random_bytes(&mut buf);
6383        let target = Id20::from(buf);
6384        match dht.sample_infohashes(target).await {
6385            Ok(result) => {
6386                post_alert(
6387                    &self.alert_tx,
6388                    &self.alert_mask,
6389                    AlertKind::DhtSampleInfohashes {
6390                        num_samples: result.samples.len(),
6391                        total_estimate: result.num,
6392                    },
6393                );
6394            }
6395            Err(e) => {
6396                debug!("sample_infohashes failed: {e}");
6397            }
6398        }
6399    }
6400
6401    /// M245 C1 — fan out `stats()` to every torrent ONCE (bounded per-torrent),
6402    /// returning the `(info_hash, stats)` pairs that answered within budget.
6403    ///
6404    /// L2 (M241): issue every per-torrent `stats()` request up front and collect
6405    /// replies as they arrive, instead of awaiting each sequentially (audit
6406    /// Tier-2 L2 — head-of-line blocking on the recv loop). Each request is
6407    /// bounded by a 500 ms timeout mirroring `make_debug_state` (eng-review F5):
6408    /// this runs ON the recv loop, so without a bound a single wedged
6409    /// `TorrentActor` would freeze all session commands indefinitely. A torrent
6410    /// that times out contributes nothing to THIS sample (a rare transient
6411    /// counter dip — the same trade-off `make_debug_state` already makes) rather
6412    /// than hanging the whole engine.
6413    ///
6414    /// The single fan-out feeds BOTH the [`SessionStats`] sums and the published
6415    /// [`SessionSnapshot`] refresh in [`make_session_stats`](Self::make_session_stats)
6416    /// — there is no second per-torrent round-trip per tick.
6417    async fn collect_torrent_stats(&self) -> Vec<(Id20, TorrentStats)> {
6418        use futures::stream::{FuturesUnordered, StreamExt};
6419
6420        let mut futs: FuturesUnordered<_> = self
6421            .torrents
6422            .iter()
6423            .map(|(&info_hash, entry)| {
6424                let handle = entry.handle.clone();
6425                async move {
6426                    tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats())
6427                        .await
6428                        .ok()
6429                        .and_then(Result::ok)
6430                        .map(|stats| (info_hash, stats))
6431                }
6432            })
6433            .collect();
6434
6435        let mut out = Vec::with_capacity(self.torrents.len());
6436        while let Some(maybe) = futs.next().await {
6437            if let Some(pair) = maybe {
6438                out.push(pair);
6439            }
6440        }
6441        out
6442    }
6443
6444    /// M245 A1 — eager membership: publish ONE torrent's current summary into
6445    /// the snapshot the instant its add commits, so reads are read-after-write
6446    /// consistent (the torrent is visible immediately, not one stats tick
6447    /// later). One bounded round-trip for THIS torrent only — NOT a fan-out.
6448    /// If the freshly-spawned actor doesn't answer within the budget, the
6449    /// torrent simply appears on the next tick (the snapshot rebuilds its
6450    /// membership from `self.torrents` every tick — see `make_session_stats`).
6451    async fn snapshot_publish_one(&self, info_hash: Id20) {
6452        let handle = match self.torrents.get(&info_hash) {
6453            Some(entry) => entry.handle.clone(),
6454            None => return,
6455        };
6456        if let Ok(Ok(stats)) =
6457            tokio::time::timeout(std::time::Duration::from_millis(500), handle.stats()).await
6458        {
6459            let mut map = self.snapshot.load().as_map().clone();
6460            map.insert(info_hash, TorrentSummary::from(&stats));
6461            self.snapshot
6462                .store(Arc::new(SessionSnapshot::from_map(map)));
6463        }
6464    }
6465
6466    /// M245 A1 — eager membership: drop ONE torrent from the snapshot the
6467    /// instant its removal commits (read-after-write). Pure map edit, no
6468    /// round-trip.
6469    fn snapshot_drop_one(&self, info_hash: Id20) {
6470        let mut map = self.snapshot.load().as_map().clone();
6471        if map.remove(&info_hash).is_some() {
6472            self.snapshot
6473                .store(Arc::new(SessionSnapshot::from_map(map)));
6474        }
6475    }
6476
6477    /// M251/ER3: resolve the externally-reachable address from what the
6478    /// session knows. Decision table (plan D2):
6479    /// - no discovered IP → `None`
6480    /// - mapped TCP port known → `ip:mapped`
6481    /// - else nonzero configured listen port → `ip:listen_port`
6482    /// - else (ephemeral port, no mapping) → `None` — never fabricate port 0
6483    fn compute_external_address(
6484        external_ip: Option<std::net::IpAddr>,
6485        external_tcp_port: Option<u16>,
6486        listen_port: u16,
6487    ) -> Option<std::net::SocketAddr> {
6488        let ip = external_ip?;
6489        let port = external_tcp_port.or_else(|| (listen_port != 0).then_some(listen_port))?;
6490        Some(std::net::SocketAddr::new(ip, port))
6491    }
6492
6493    async fn make_session_stats(&self) -> SessionStats {
6494        self.update_session_gauges();
6495
6496        let active_torrents = self.torrents.len();
6497        let dht_nodes = usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some());
6498
6499        // C1 (M245): ONE fan-out per tick. Sum the session counters AND refresh
6500        // the published snapshot from the SAME (id, stats) pairs — no second
6501        // round-trip.
6502        let collected = self.collect_torrent_stats().await;
6503
6504        let mut total_downloaded = 0u64;
6505        let mut total_uploaded = 0u64;
6506        let mut responded = std::collections::HashMap::with_capacity(collected.len());
6507        for (info_hash, stats) in collected {
6508            total_downloaded = total_downloaded.saturating_add(stats.downloaded);
6509            total_uploaded = total_uploaded.saturating_add(stats.uploaded);
6510            responded.insert(info_hash, stats);
6511        }
6512
6513        // Rebuild the snapshot keyed by the LIVE membership (`self.torrents` is
6514        // the source of truth — this self-heals any eager-hook drift). A torrent
6515        // that answered gets a fresh summary; one that timed out THIS tick
6516        // carries forward its previous summary (eventually-consistent to one
6517        // tick — the D2 contract) rather than vanishing from reads.
6518        let prev = self.snapshot.load();
6519        let mut map = std::collections::BTreeMap::new();
6520        for &info_hash in self.torrents.keys() {
6521            if let Some(stats) = responded.get(&info_hash) {
6522                map.insert(info_hash, TorrentSummary::from(stats));
6523            } else if let Some(prev_summary) = prev.as_map().get(&info_hash) {
6524                map.insert(info_hash, prev_summary.clone());
6525            }
6526        }
6527        self.snapshot
6528            .store(Arc::new(SessionSnapshot::from_map(map)));
6529
6530        SessionStats {
6531            active_torrents,
6532            total_downloaded,
6533            total_uploaded,
6534            dht_nodes,
6535            external_address: Self::compute_external_address(
6536                self.external_ip,
6537                self.external_tcp_port,
6538                self.settings.listen_port,
6539            ),
6540            incoming_peer_connections: self.incoming_peer_connections,
6541        }
6542    }
6543
6544    /// Build a debug state snapshot across all torrents, collecting per-torrent
6545    /// stats and per-peer details. Torrents that time out are skipped so a
6546    /// single slow actor never blocks the whole response.
6547    async fn make_debug_state(&self) -> crate::types::DebugState {
6548        use crate::stats::{
6549            DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6550            DISPATCH_NOTIFY_WAKEUP_TOTAL,
6551        };
6552
6553        // Session-wide dispatch counters from the atomic counters array.
6554        let snap = self.counters.snapshot();
6555        let dispatch = crate::types::DebugDispatchState {
6556            acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6557            acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6558            acquire_us: snap[DISPATCH_ACQUIRE_US],
6559            notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6560            pieces_queued: 0,
6561            pieces_inflight: 0,
6562        };
6563
6564        let mut torrents = Vec::with_capacity(self.torrents.len());
6565        for (&info_hash, entry) in &self.torrents {
6566            // Per-torrent stats — skip if the actor is slow.
6567            let Ok(Ok(stats)) =
6568                tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6569                    .await
6570            else {
6571                continue;
6572            };
6573
6574            // Per-peer details — skip on timeout.
6575            let peers_raw = match tokio::time::timeout(
6576                std::time::Duration::from_millis(500),
6577                entry.handle.get_peer_info(),
6578            )
6579            .await
6580            {
6581                Ok(Ok(p)) => p,
6582                _ => Vec::new(),
6583            };
6584
6585            let peers: Vec<crate::types::DebugPeerState> = peers_raw
6586                .iter()
6587                .map(|p| crate::types::DebugPeerState {
6588                    addr: p.addr,
6589                    in_flight: p.in_flight_requests,
6590                    target_depth: p.target_pipeline_depth,
6591                    choking: p.peer_choking,
6592                    download_rate: p.download_rate,
6593                })
6594                .collect();
6595
6596            let mut per_torrent_dispatch = dispatch.clone();
6597            per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6598            per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6599
6600            torrents.push(crate::types::DebugTorrentState {
6601                info_hash: info_hash.to_hex(),
6602                state: format!("{:?}", stats.state),
6603                num_peers: stats.peers_connected,
6604                dispatch: per_torrent_dispatch,
6605                peers,
6606            });
6607        }
6608
6609        crate::types::DebugState { torrents }
6610    }
6611
6612    async fn handle_save_torrent_resume(
6613        &self,
6614        info_hash: Id20,
6615    ) -> crate::Result<irontide_core::FastResumeData> {
6616        let entry = self
6617            .torrents
6618            .get(&info_hash)
6619            .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6620        let mut resume = entry.handle.save_resume_data().await?;
6621        // Patch in queue state from SessionActor's TorrentEntry (the
6622        // TorrentHandle doesn't know about queue position / auto-managed).
6623        resume.queue_position = i64::from(entry.queue_position);
6624        resume.auto_managed = i64::from(entry.auto_managed);
6625        Ok(resume)
6626    }
6627
6628    async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6629        use crate::persistence::SessionState;
6630
6631        let mut torrents = Vec::new();
6632        for (info_hash, entry) in &self.torrents {
6633            match entry.handle.save_resume_data().await {
6634                Ok(rd) => torrents.push(rd),
6635                Err(e) => {
6636                    warn!(%info_hash, "failed to save resume data: {e}");
6637                }
6638            }
6639        }
6640
6641        // Serialize smart ban state (scoped to drop RwLockReadGuard before awaits)
6642        let (banned_peers, peer_strikes) = {
6643            let ban_mgr = self.ban_manager.read();
6644            let banned_peers: Vec<String> = ban_mgr
6645                .banned_list()
6646                .iter()
6647                .map(std::string::ToString::to_string)
6648                .collect();
6649            let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6650                .strikes_map()
6651                .iter()
6652                .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6653                    ip: ip.to_string(),
6654                    count: i64::from(count),
6655                })
6656                .collect();
6657            (banned_peers, peer_strikes)
6658        };
6659
6660        let mut dht_entries = Vec::new();
6661        let mut dht_node_id = None;
6662        if let Some(ref dht) = self.dht_v4 {
6663            // Save the (possibly BEP 42-regenerated) node ID for next session
6664            if let Ok(stats) = dht.stats().await {
6665                dht_node_id = Some(stats.node_id.to_hex());
6666            }
6667            for (_id, addr) in dht.get_routing_nodes().await {
6668                dht_entries.push(crate::persistence::DhtNodeEntry {
6669                    host: addr.ip().to_string(),
6670                    port: i64::from(addr.port()),
6671                });
6672            }
6673        }
6674        if let Some(ref dht) = self.dht_v6 {
6675            for (_id, addr) in dht.get_routing_nodes().await {
6676                dht_entries.push(crate::persistence::DhtNodeEntry {
6677                    host: addr.ip().to_string(),
6678                    port: i64::from(addr.port()),
6679                });
6680            }
6681        }
6682
6683        Ok(SessionState {
6684            dht_nodes: dht_entries,
6685            dht_node_id,
6686            torrents,
6687            banned_peers,
6688            peer_strikes,
6689        })
6690    }
6691
6692    /// Compute the effective resume data directory from settings.
6693    fn effective_resume_dir(&self) -> PathBuf {
6694        self.settings
6695            .resume_data_dir
6696            .clone()
6697            .unwrap_or_else(crate::resume_file::default_resume_dir)
6698    }
6699
6700    /// Load and restore torrents from per-torrent resume files on disk.
6701    ///
6702    /// Scans the resume directory, deserializes each `.resume` file, reconstructs
6703    /// the torrent metadata or magnet, adds it to the session, and restores the
6704    /// piece bitmap where available.
6705    async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6706        let resume_dir = self.effective_resume_dir();
6707        let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6708
6709        let mut restored = 0usize;
6710        let mut skipped = 0usize;
6711        let mut failed = 0usize;
6712
6713        for path in &paths {
6714            let file_name = path
6715                .file_name()
6716                .and_then(|n| n.to_str())
6717                .unwrap_or("<unknown>");
6718
6719            // Read and deserialize
6720            let bytes = match std::fs::read(path) {
6721                Ok(b) => b,
6722                Err(e) => {
6723                    warn!(file = %file_name, "failed to read resume file: {e}");
6724                    failed = failed.saturating_add(1);
6725                    continue;
6726                }
6727            };
6728
6729            let rd = match crate::resume_file::deserialize_resume(&bytes) {
6730                Ok(rd) => rd,
6731                Err(e) => {
6732                    warn!(file = %file_name, "failed to deserialize resume file: {e}");
6733                    failed = failed.saturating_add(1);
6734                    continue;
6735                }
6736            };
6737
6738            // Try to reconstruct as a resolved torrent (info dict present).
6739            if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6740                let info_hash = meta.info_hash;
6741                let pieces = rd.pieces.clone();
6742                let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6743
6744                // Restore to the original save_path (per-torrent download dir).
6745                let restore_dir = if rd.save_path.is_empty() {
6746                    None
6747                } else {
6748                    Some(PathBuf::from(&rd.save_path))
6749                };
6750                // M171: restore tags by baking them into the TorrentConfig
6751                // at add-time, matching the `AddTorrentParams::with_tags`
6752                // semantics. Category still goes through the post-add
6753                // fire-and-forget path (M170 behaviour preserved).
6754                let restore_tags = rd.tags.clone();
6755                // M252/ER5 (D3): restore the layout the torrent was stored
6756                // with — legacy resume files (None) were all written as
6757                // `Original`. Passing `Some(..)` pins it so a later
6758                // `create_subfolder` settings flip can't re-shape paths.
6759                // M253 (D8): same pin rule for the two ordering flags —
6760                // legacy files carry 0 (off), matching prior behaviour.
6761                let restore_overrides = AddConfigOverrides {
6762                    content_layout: Some(rd.content_layout.unwrap_or_default()),
6763                    sequential_download: Some(rd.sequential_download != 0),
6764                    prioritize_first_last_pieces: Some(rd.prioritize_first_last_pieces != 0),
6765                    preallocate_mode: None,
6766                    // M254 (D5): rd.file_priority has been SAVED since the
6767                    // resume format gained it but never restored — map it
6768                    // back; priorities survive restarts for the first time.
6769                    // Out-of-range legacy values clamp to Normal.
6770                    file_priorities: rd
6771                        .file_priority
6772                        .iter()
6773                        .map(|&v| irontide_core::FilePriority::from(u8::try_from(v).unwrap_or(4)))
6774                        .collect(),
6775                    // M254 (OV-F1): construct the entry with the RESTORED
6776                    // truth — avoids a pointless next_queue_position() that
6777                    // the post-insert patch (rd.queue_position +
6778                    // rd.auto_managed below) immediately overwrites. The
6779                    // patch path stays the position authority.
6780                    auto_managed: Some(rd.auto_managed != 0),
6781                };
6782                match self
6783                    .handle_add_torrent(
6784                        torrent_meta,
6785                        None,
6786                        restore_dir,
6787                        restore_tags,
6788                        restore_overrides,
6789                    )
6790                    .await
6791                {
6792                    Ok(added_hash) => {
6793                        // Restore the piece bitmap if non-empty.
6794                        if !pieces.is_empty()
6795                            && let Some(entry) = self.torrents.get(&added_hash)
6796                            && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6797                        {
6798                            warn!(
6799                                %info_hash,
6800                                "failed to restore piece bitmap, torrent will recheck: {e}"
6801                            );
6802                        }
6803                        // M170: restore the category label if persisted.
6804                        if let Some(ref cat) = rd.category
6805                            && let Some(entry) = self.torrents.get(&added_hash)
6806                        {
6807                            let handle = entry.handle.clone();
6808                            let cat_owned = cat.clone();
6809                            tokio::spawn(async move {
6810                                let _ = handle.set_category(Some(cat_owned)).await;
6811                            });
6812                        }
6813                        // M178: restore per-URL web-seed stats so cumulative
6814                        // bytes and error history survive app restart.
6815                        if !rd.web_seed_stats.is_empty()
6816                            && let Some(entry) = self.torrents.get(&added_hash)
6817                        {
6818                            let handle = entry.handle.clone();
6819                            let stats_owned = rd.web_seed_stats.clone();
6820                            tokio::spawn(async move {
6821                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6822                            });
6823                        }
6824                        if self.settings.queueing_enabled
6825                            && let Some(entry) = self.torrents.get(&added_hash)
6826                        {
6827                            let _ = entry.handle.queue().await;
6828                        }
6829                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6830                            entry.queue_position = rd.queue_position as i32;
6831                            entry.auto_managed = rd.auto_managed != 0;
6832                        }
6833                        info!(%info_hash, "restored torrent from resume file");
6834                        restored = restored.saturating_add(1);
6835                    }
6836                    Err(crate::Error::DuplicateTorrent(_)) => {
6837                        debug!(%info_hash, "skipped duplicate torrent from resume");
6838                        skipped = skipped.saturating_add(1);
6839                    }
6840                    Err(e) => {
6841                        warn!(%info_hash, "failed to add restored torrent: {e}");
6842                        failed = failed.saturating_add(1);
6843                    }
6844                }
6845            } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6846                // Unresolved magnet: re-add as magnet link.
6847                let info_hash = magnet.info_hash();
6848                let restore_dir = if rd.save_path.is_empty() {
6849                    None
6850                } else {
6851                    Some(PathBuf::from(&rd.save_path))
6852                };
6853                // M171: restore tags via the add-time config bake path.
6854                let restore_tags = rd.tags.clone();
6855                // M252/ER5 (D3) + M253 (D8) + M254 (D5/OV-F1): same
6856                // pin-to-stored-values rule as the resolved-torrent restore
6857                // arm above (file priorities apply at metadata resolution).
6858                let restore_overrides = AddConfigOverrides {
6859                    content_layout: Some(rd.content_layout.unwrap_or_default()),
6860                    sequential_download: Some(rd.sequential_download != 0),
6861                    prioritize_first_last_pieces: Some(rd.prioritize_first_last_pieces != 0),
6862                    preallocate_mode: None,
6863                    file_priorities: rd
6864                        .file_priority
6865                        .iter()
6866                        .map(|&v| irontide_core::FilePriority::from(u8::try_from(v).unwrap_or(4)))
6867                        .collect(),
6868                    auto_managed: Some(rd.auto_managed != 0),
6869                };
6870                match self
6871                    .handle_add_magnet(magnet, restore_dir, restore_tags, restore_overrides)
6872                    .await
6873                {
6874                    Ok(added_hash) => {
6875                        // M170: restore category on magnet too.
6876                        if let Some(ref cat) = rd.category
6877                            && let Some(entry) = self.torrents.get(&added_hash)
6878                        {
6879                            let handle = entry.handle.clone();
6880                            let cat_owned = cat.clone();
6881                            tokio::spawn(async move {
6882                                let _ = handle.set_category(Some(cat_owned)).await;
6883                            });
6884                        }
6885                        // M178: restore per-URL web-seed stats on magnet too,
6886                        // so resumes from a magnet-added torrent that already
6887                        // had web-seed activity recover correctly.
6888                        if !rd.web_seed_stats.is_empty()
6889                            && let Some(entry) = self.torrents.get(&added_hash)
6890                        {
6891                            let handle = entry.handle.clone();
6892                            let stats_owned = rd.web_seed_stats.clone();
6893                            tokio::spawn(async move {
6894                                let _ = handle.restore_web_seed_stats(stats_owned).await;
6895                            });
6896                        }
6897                        if self.settings.queueing_enabled
6898                            && let Some(entry) = self.torrents.get(&added_hash)
6899                        {
6900                            let _ = entry.handle.queue().await;
6901                        }
6902                        if let Some(entry) = self.torrents.get_mut(&added_hash) {
6903                            entry.queue_position = rd.queue_position as i32;
6904                            entry.auto_managed = rd.auto_managed != 0;
6905                        }
6906                        info!(%info_hash, "restored magnet from resume file");
6907                        restored = restored.saturating_add(1);
6908                    }
6909                    Err(crate::Error::DuplicateTorrent(_)) => {
6910                        debug!(%info_hash, "skipped duplicate magnet from resume");
6911                        skipped = skipped.saturating_add(1);
6912                    }
6913                    Err(e) => {
6914                        warn!(%info_hash, "failed to add restored magnet: {e}");
6915                        failed = failed.saturating_add(1);
6916                    }
6917                }
6918            } else {
6919                warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6920                failed = failed.saturating_add(1);
6921            }
6922        }
6923
6924        // Renormalize queue positions to contiguous 0..N-1. Handles
6925        // duplicate positions from crash mid-save, manual edits, or
6926        // older resume formats that default all positions to 0.
6927        {
6928            let mut entries: Vec<(Id20, i32)> = self
6929                .torrents
6930                .iter()
6931                .filter(|(_, e)| e.auto_managed)
6932                .map(|(h, e)| (*h, e.queue_position))
6933                .collect();
6934            entries.sort_by_key(|&(_, pos)| pos);
6935            for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6936                if let Some(entry) = self.torrents.get_mut(&hash) {
6937                    entry.queue_position = new_pos as i32;
6938                }
6939            }
6940        }
6941
6942        info!(restored, skipped, failed, "resume state loaded");
6943        Ok(ResumeLoadResult {
6944            restored,
6945            skipped,
6946            failed,
6947        })
6948    }
6949
6950    /// Save resume files for all torrents with a dirty `need_save_resume` flag.
6951    ///
6952    /// Returns the number of resume files successfully written.
6953    /// Snapshot the data `run_resume_save_jobs` needs while we hold `&self` on
6954    /// the actor. Cheap — clones channel-sender handles + copies queue metadata.
6955    fn snapshot_resume_jobs(&self) -> (std::path::PathBuf, Vec<ResumeSaveJob>) {
6956        let resume_dir = self.effective_resume_dir();
6957        let jobs = self
6958            .torrents
6959            .iter()
6960            .map(|(info_hash, entry)| ResumeSaveJob {
6961                info_hash: *info_hash,
6962                handle: entry.handle.clone(),
6963                queue_position: i64::from(entry.queue_position),
6964                auto_managed: i64::from(entry.auto_managed),
6965            })
6966            .collect();
6967        (resume_dir, jobs)
6968    }
6969
6970    /// Save resume files for all dirty torrents, inline. Retained for the
6971    /// shutdown path, which MUST await the save to completion before the process
6972    /// exits (terminal — recv-loop liveness no longer matters there). Acquires
6973    /// `resume_save_lock` first so it waits behind any in-flight spawned save
6974    /// rather than racing it onto the same temp path (F4); since shutdown runs
6975    /// this BEFORE draining torrents, the awaited periodic save still completes.
6976    /// The periodic timer + `SaveResumeState` RPC instead spawn
6977    /// `run_resume_save_jobs` while holding the lock, so they never block the loop.
6978    async fn save_dirty_resume_files(&self) -> usize {
6979        let _guard = self.resume_save_lock.lock().await;
6980        let (resume_dir, jobs) = self.snapshot_resume_jobs();
6981        run_resume_save_jobs(resume_dir, jobs).await
6982    }
6983
6984    /// Apply new settings at runtime, transactionally (M173 Lane B, B1).
6985    ///
6986    /// Phases (executed in order; rollback in REVERSE on first failure):
6987    ///
6988    /// 1. Rate limits + alert mask (cheap; rollback restores)
6989    /// 2. Listen-port rebind (B4 wires the real reconfig — B1 stub no-op)
6990    /// 3. DHT enable/disable (B5-B7 wire — B1 stub no-op)
6991    /// 4. LSD enable/disable (B9 wires — B1 stub no-op)
6992    ///
6993    /// On any phase failure, already-applied phases roll back (LIFO) and
6994    /// the caller sees the original [`crate::Error`] that triggered the
6995    /// failure. Until B4-B9 land, phases 2-4 are no-ops, so this method
6996    /// behaves identically to the M171 implementation for callers that
6997    /// only patch rate limits / alert mask. Listen-port / DHT / LSD
6998    /// changes are still classified as "`restart_required`" by
6999    /// [`classify_restart_required`] until B10 graduates them.
7000    ///
7001    /// # Errors
7002    ///
7003    /// Returns [`crate::Error::InvalidSettings`] if `Settings::validate`
7004    /// rejects the new patch. Future variants from B4-B9 will surface
7005    /// listener / DHT / LSD restart failures via [`crate::apply::ApplyError`].
7006    fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
7007        // Validate FIRST so that an invalid patch never even enters the
7008        // transactional pipeline. This matches the M171 behaviour.
7009        new.validate()?;
7010
7011        // Snapshot pre-call values for rollback closures. These are
7012        // captured by-value so the rollback closures can run without
7013        // borrowing `self` (the executor needs `&mut self`).
7014        let old_upload_rate = self.settings.upload_rate_limit;
7015        let old_download_rate = self.settings.download_rate_limit;
7016        let old_alert_mask = self.settings.alert_mask;
7017        let old_settings = self.settings.clone();
7018        let old_settings_for_delta = self.settings.clone();
7019
7020        let new_upload_rate = new.upload_rate_limit;
7021        let new_download_rate = new.download_rate_limit;
7022        let new_alert_mask = new.alert_mask;
7023
7024        // Phase 1: rate limits + alert mask + Settings struct.
7025        // The rollback restores all four mutations (rate buckets +
7026        // mask atomic + Settings struct) — Settings is cloned for
7027        // restoration so the caller sees the original on failure.
7028        let upload_bucket = Arc::clone(&self.global_upload_bucket);
7029        let download_bucket = Arc::clone(&self.global_download_bucket);
7030        let alert_mask = Arc::clone(&self.alert_mask);
7031
7032        let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
7033            name: "rate_limits_and_mask",
7034            forward: Box::new(move |this: &mut Self| {
7035                if new_upload_rate != old_upload_rate {
7036                    upload_bucket.lock().set_rate(new_upload_rate);
7037                }
7038                if new_download_rate != old_download_rate {
7039                    download_bucket.lock().set_rate(new_download_rate);
7040                }
7041                if new_alert_mask != old_alert_mask {
7042                    alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
7043                }
7044                this.settings = new;
7045                Ok(())
7046            }),
7047            rollback: Box::new(move |this: &mut Self| {
7048                // Restore in the reverse order of forward mutations.
7049                this.settings = old_settings;
7050                if new_alert_mask != old_alert_mask {
7051                    this.alert_mask
7052                        .store(old_alert_mask.bits(), Ordering::Relaxed);
7053                }
7054                if new_download_rate != old_download_rate {
7055                    this.global_download_bucket
7056                        .lock()
7057                        .set_rate(old_download_rate);
7058                }
7059                if new_upload_rate != old_upload_rate {
7060                    this.global_upload_bucket.lock().set_rate(old_upload_rate);
7061                }
7062            }),
7063        };
7064
7065        // Phase 2 (listen_port) and Phase 4 (LSD) remain unimplemented.
7066        let phases = vec![phase1];
7067
7068        match crate::apply::apply_phases_with_rollback(self, phases) {
7069            Ok(()) => {
7070                // M226 Step 5: broadcast the new Settings to the
7071                // notification dispatcher. `send` on a watch channel
7072                // returns Err only when every receiver has dropped,
7073                // which means the dispatcher has already exited — at
7074                // that point the toggle is academic and we silently
7075                // discard the error so apply_settings stays infallible
7076                // on the notification axis.
7077                let _ = self.notification_settings_tx.send(self.settings.clone());
7078
7079                // Phase 3: DHT toggle (v0.187.1).
7080                if (old_settings_for_delta.enable_dht != self.settings.enable_dht
7081                    || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
7082                    && (!self.settings.enable_dht || self.settings.anonymous_mode)
7083                {
7084                    tracing::info!("DHT disabled via settings");
7085                    self.dht_v4 = None;
7086                    self.dht_v6 = None;
7087                    self.dht_v4_broadcast.replace(None);
7088                    self.dht_v6_broadcast.replace(None);
7089                }
7090
7091                // M224 D3: keep the listener's cap atomic in sync. The
7092                // listener reads this on every TCP accept, so the new cap
7093                // applies to the next accepted connection without a restart.
7094                // Update is unconditional — `store` is cheap, and skipping
7095                // when unchanged would require an additional read first.
7096                self.max_connections_global.store(
7097                    self.settings.max_connections_global,
7098                    std::sync::atomic::Ordering::SeqCst,
7099                );
7100
7101                let delta =
7102                    crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
7103                if delta.save_resume_interval_secs.is_some() {
7104                    self.resume_save_notify.notify_one();
7105                }
7106                if let Some(enabled) = delta.ip_filter_enabled {
7107                    self.ip_filter.write().enabled = enabled;
7108                }
7109                // M226 Step 6: poke the watched-folder dispatcher to
7110                // rebuild its debouncer against the new path. We only
7111                // fire when the path itself OR the delete-after-add
7112                // flag changed — rate-limit / DHT tweaks must NOT
7113                // churn inotify FDs.
7114                if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
7115                    self.watched_folder_changed.notify_one();
7116                }
7117                // M255/ER1: rebuild the GeoIP resolver only when its two
7118                // settings moved — DB open is a file read, not something
7119                // to churn on unrelated rate-limit tweaks.
7120                if delta.resolve_peer_countries.is_some() || delta.peer_country_db_path.is_some() {
7121                    self.geoip = crate::geoip::build_geoip_resolver(&self.settings);
7122                }
7123                if !delta.is_empty() {
7124                    // v0.187.3 / 1A: SettingsDelta fan-out. We use try_send so a
7125                    // saturated per-torrent channel doesn't block the apply call;
7126                    // the trade-off is that the dropped torrent will retain its
7127                    // old config until the next apply. Pre-v0.187.3 this was a
7128                    // silent `let _`; now we log at WARN so partial-failure
7129                    // events are observable without becoming fatal.
7130                    let mut failed: Vec<irontide_core::Id20> = Vec::new();
7131                    for (hash, entry) in &self.torrents {
7132                        if entry
7133                            .handle
7134                            .cmd_tx
7135                            .try_send(crate::types::TorrentCommand::UpdateSettings(Box::new(
7136                                delta.clone(),
7137                            )))
7138                            .is_err()
7139                        {
7140                            failed.push(*hash);
7141                        }
7142                    }
7143                    if !failed.is_empty() {
7144                        tracing::warn!(
7145                            count = failed.len(),
7146                            "SettingsDelta fan-out: per-torrent channel saturated; \
7147                             affected torrents will pick up the change on the next apply"
7148                        );
7149                    }
7150                }
7151                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
7152                Ok(())
7153            }
7154            Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
7155                Err(crate::Error::InvalidSettings(msg))
7156            }
7157            Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
7158        }
7159    }
7160
7161    /// Build a `QueueEntry` snapshot from current auto-managed torrents.
7162    fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
7163        self.torrents
7164            .iter()
7165            .filter(|(_, e)| e.auto_managed)
7166            .map(|(&hash, e)| crate::queue::QueueEntry {
7167                info_hash: hash,
7168                position: e.queue_position,
7169            })
7170            .collect()
7171    }
7172
7173    fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
7174        if !self.torrents.contains_key(&info_hash) {
7175            return Err(crate::Error::TorrentNotFound(info_hash));
7176        }
7177        let mut entries = self.queue_entries();
7178        let changed = crate::queue::set_position(&mut entries, info_hash, pos);
7179        self.apply_queue_changes(&changed);
7180        Ok(())
7181    }
7182
7183    /// M254 (D3): flip a torrent's auto-managed state. Turning it ON
7184    /// assigns the next queue position (if unpositioned); turning it OFF
7185    /// removes it from the queue and renumbers the rest. Alerts fire via
7186    /// `apply_queue_changes` on every position write (OV-F2). Idempotent.
7187    /// Serves both the `SetAutoManaged` command and the
7188    /// `SetFlags`/`UnsetFlags` `AUTO_MANAGED`-bit interception (M253 D5
7189    /// precedent: every door to the same state mutates it the same way).
7190    fn set_auto_managed_inner(&mut self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
7191        let Some(entry) = self.torrents.get_mut(&info_hash) else {
7192            return Err(crate::Error::TorrentNotFound(info_hash));
7193        };
7194        if entry.auto_managed == enabled {
7195            return Ok(());
7196        }
7197        entry.auto_managed = enabled;
7198        if enabled {
7199            if entry.queue_position < 0 {
7200                let pos = self.next_queue_position();
7201                self.apply_queue_changes(&[(info_hash, -1, pos)]);
7202            }
7203        } else {
7204            let old_pos = entry.queue_position;
7205            entry.queue_position = -1;
7206            if old_pos >= 0 {
7207                // The entry is already excluded from `queue_entries()`
7208                // (auto_managed=false), so `remove_position`'s retain is a
7209                // no-op and the shift-down renumbers only the survivors.
7210                let mut entries = self.queue_entries();
7211                let changed = crate::queue::remove_position(&mut entries, old_pos);
7212                self.apply_queue_changes(&changed);
7213            }
7214        }
7215        Ok(())
7216    }
7217
7218    fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
7219        if !self.torrents.contains_key(&info_hash) {
7220            return Err(crate::Error::TorrentNotFound(info_hash));
7221        }
7222        let mut entries = self.queue_entries();
7223        let changed = op(&mut entries, info_hash);
7224        self.apply_queue_changes(&changed);
7225        Ok(())
7226    }
7227
7228    /// Apply position changes back to `TorrentEntry` fields and fire alerts.
7229    fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
7230        for &(hash, old_pos, new_pos) in changed {
7231            if let Some(entry) = self.torrents.get_mut(&hash) {
7232                entry.queue_position = new_pos;
7233            }
7234            crate::alert::post_alert(
7235                &self.alert_tx,
7236                &self.alert_mask,
7237                crate::alert::AlertKind::TorrentQueuePositionChanged {
7238                    info_hash: hash,
7239                    old_pos,
7240                    new_pos,
7241                },
7242            );
7243        }
7244    }
7245
7246    async fn evaluate_queue(&mut self) {
7247        if !self.settings.queueing_enabled {
7248            return;
7249        }
7250        let now = tokio::time::Instant::now();
7251        let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
7252        let mut candidates = Vec::new();
7253
7254        // Collect info hashes first to avoid borrow issues with async calls
7255        let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
7256
7257        for &info_hash in &hashes {
7258            let (queue_position, started_at) = {
7259                let Some(entry) = self.torrents.get(&info_hash) else {
7260                    continue;
7261                };
7262                if !entry.auto_managed {
7263                    continue;
7264                }
7265                (entry.queue_position, entry.started_at)
7266            };
7267
7268            // Get current stats (async call — self.torrents is not borrowed here)
7269            let stats = match self.torrents.get(&info_hash) {
7270                Some(entry) => match entry.handle.stats().await {
7271                    Ok(s) => s,
7272                    Err(_) => continue,
7273                },
7274                None => continue,
7275            };
7276
7277            let category = match stats.state {
7278                TorrentState::Checking | TorrentState::FetchingMetadata => {
7279                    crate::queue::QueueCategory::Checking
7280                }
7281                TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
7282                TorrentState::Seeding | TorrentState::Complete => {
7283                    crate::queue::QueueCategory::Seeding
7284                }
7285                TorrentState::Queued => {
7286                    if stats.progress >= 1.0 {
7287                        crate::queue::QueueCategory::Seeding
7288                    } else {
7289                        crate::queue::QueueCategory::Downloading
7290                    }
7291                }
7292                TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
7293            };
7294
7295            let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
7296
7297            // EWMA-smooth the rates for stable inactive classification.
7298            let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
7299            let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
7300            {
7301                let raw_dl = stats.download_rate as f64;
7302                let raw_ul = stats.upload_rate as f64;
7303                entry.smoothed_download_rate =
7304                    alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
7305                entry.smoothed_upload_rate =
7306                    alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
7307                (entry.smoothed_download_rate, entry.smoothed_upload_rate)
7308            } else {
7309                continue;
7310            };
7311
7312            let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
7313
7314            let is_inactive = past_startup
7315                && match category {
7316                    crate::queue::QueueCategory::Downloading => {
7317                        (smoothed_dl as u64) < self.settings.inactive_down_rate
7318                    }
7319                    crate::queue::QueueCategory::Seeding => {
7320                        (smoothed_ul as u64) < self.settings.inactive_up_rate
7321                    }
7322                    crate::queue::QueueCategory::Checking => false,
7323                };
7324
7325            let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
7326                std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
7327            } else {
7328                startup_duration
7329            };
7330            let recently_started =
7331                started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
7332
7333            let seed_rank = if category == crate::queue::QueueCategory::Seeding {
7334                Some(crate::queue::compute_seed_rank(
7335                    stats.num_complete,
7336                    stats.num_incomplete,
7337                ))
7338            } else {
7339                None
7340            };
7341
7342            candidates.push(crate::queue::QueueCandidate {
7343                info_hash,
7344                position: queue_position,
7345                category,
7346                is_active,
7347                is_inactive,
7348                recently_started,
7349                seed_rank,
7350            });
7351        }
7352
7353        let config = crate::queue::QueueConfig {
7354            active_downloads: self.settings.active_downloads,
7355            active_seeds: self.settings.active_seeds,
7356            active_checking: self.settings.active_checking,
7357            active_limit: self.settings.active_limit,
7358            dont_count_slow: self.settings.dont_count_slow_torrents,
7359            prefer_seeds: self.settings.auto_manage_prefer_seeds,
7360        };
7361        let mut decision = crate::queue::evaluate(&candidates, &config);
7362        crate::queue::apply_preemption(&mut decision, &candidates);
7363
7364        // Apply decisions
7365        for hash in &decision.to_pause {
7366            if let Some(entry) = self.torrents.get(hash) {
7367                let _ = entry.handle.queue().await;
7368            }
7369            post_alert(
7370                &self.alert_tx,
7371                &self.alert_mask,
7372                AlertKind::TorrentAutoManaged {
7373                    info_hash: *hash,
7374                    paused: true,
7375                },
7376            );
7377        }
7378
7379        for hash in &decision.to_resume {
7380            if let Some(entry) = self.torrents.get_mut(hash) {
7381                let _ = entry.handle.resume().await;
7382                entry.started_at = Some(tokio::time::Instant::now());
7383            }
7384            post_alert(
7385                &self.alert_tx,
7386                &self.alert_mask,
7387                AlertKind::TorrentAutoManaged {
7388                    info_hash: *hash,
7389                    paused: false,
7390                },
7391            );
7392        }
7393    }
7394
7395    /// Handle a pre-validated inbound connection from the `ListenerTask` (M114).
7396    fn handle_identified_inbound(&mut self, conn: crate::listener::IdentifiedConnection) {
7397        if let Some(entry) = self.torrents.get(&conn.info_hash) {
7398            debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
7399            // M251/ER6: a validated inbound peer reached our listen port and
7400            // is being routed — the passive reachability signal.
7401            self.incoming_peer_connections += 1;
7402            let handle = entry.handle.clone();
7403            tokio::spawn(async move {
7404                let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
7405            });
7406        } else {
7407            // Race: torrent removed between validation and receipt.
7408            debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
7409        }
7410    }
7411
7412    /// Handle an incoming SSL/TLS connection (M42).
7413    ///
7414    /// Uses `LazyConfigAcceptor` to peek at the TLS `ClientHello` and extract
7415    /// the SNI (hex-encoded info hash) to route the connection to the right
7416    /// torrent. The full TLS handshake uses the torrent's CA cert to build
7417    /// the server config.
7418    async fn handle_ssl_incoming(
7419        &mut self,
7420        stream: crate::transport::BoxedStream,
7421        addr: std::net::SocketAddr,
7422    ) {
7423        use tokio_rustls::LazyConfigAcceptor;
7424
7425        let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
7426
7427        let start_handshake = match acceptor.await {
7428            Ok(sh) => sh,
7429            Err(e) => {
7430                debug!(%addr, error = %e, "SSL ClientHello read failed");
7431                return;
7432            }
7433        };
7434
7435        // Extract SNI from ClientHello
7436        let client_hello = start_handshake.client_hello();
7437        let sni = if let Some(name) = client_hello.server_name() {
7438            name.to_string()
7439        } else {
7440            debug!(%addr, "SSL connection missing SNI");
7441            return;
7442        };
7443
7444        // SNI is hex-encoded info hash (40 chars for SHA-1)
7445        let Ok(info_hash) = Id20::from_hex(&sni) else {
7446            debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
7447            return;
7448        };
7449
7450        // Look up the torrent
7451        let Some(torrent) = self.torrents.get(&info_hash) else {
7452            debug!(%addr, %info_hash, "SSL connection for unknown torrent");
7453            return;
7454        };
7455
7456        // Get the SSL CA cert from the torrent's metadata.
7457        //
7458        // v0.173.1: `TorrentEntry.meta` was deleted. Query the TorrentActor
7459        // directly (single source of truth). Magnet torrents previously hit
7460        // the "non-SSL torrent" branch here because their entry.meta was
7461        // always None — BEP 4A handshakes silently dropped.
7462        let meta = match torrent.handle.get_meta().await {
7463            Ok(Some(m)) => m,
7464            Ok(None) => {
7465                debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
7466                return;
7467            }
7468            Err(_) => {
7469                debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
7470                return;
7471            }
7472        };
7473        let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
7474            cert.clone()
7475        } else {
7476            debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
7477            return;
7478        };
7479
7480        // Build server config using the torrent's CA cert
7481        let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
7482            match mgr.server_config(&ssl_cert) {
7483                Ok(cfg) => cfg,
7484                Err(e) => {
7485                    warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
7486                    return;
7487                }
7488            }
7489        } else {
7490            debug!(%addr, "SSL manager not initialized");
7491            return;
7492        };
7493
7494        // Complete the TLS handshake
7495        let tls_stream = match start_handshake.into_stream(server_config).await {
7496            Ok(s) => s,
7497            Err(e) => {
7498                warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
7499                post_alert(
7500                    &self.alert_tx,
7501                    &self.alert_mask,
7502                    AlertKind::SslTorrentError {
7503                        info_hash,
7504                        message: format!("inbound TLS handshake from {addr}: {e}"),
7505                    },
7506                );
7507                return;
7508            }
7509        };
7510
7511        // Route to the torrent actor via SpawnSslPeer command
7512        // M251/ER6: validated inbound peer (SSL path) — same reachability
7513        // signal as the plaintext funnel.
7514        self.incoming_peer_connections += 1;
7515        let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7516    }
7517
7518    async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7519        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7520        match dht.put_immutable(value.clone()).await {
7521            Ok(target) => {
7522                post_alert(
7523                    &self.alert_tx,
7524                    &self.alert_mask,
7525                    AlertKind::DhtPutComplete { target },
7526                );
7527                Ok(target)
7528            }
7529            Err(e) => {
7530                let target = irontide_core::sha1(&value);
7531                post_alert(
7532                    &self.alert_tx,
7533                    &self.alert_mask,
7534                    AlertKind::DhtItemError {
7535                        target,
7536                        message: e.to_string(),
7537                    },
7538                );
7539                Err(crate::Error::Dht(e))
7540            }
7541        }
7542    }
7543
7544    async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7545        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7546        match dht.get_immutable(target).await {
7547            Ok(value) => {
7548                post_alert(
7549                    &self.alert_tx,
7550                    &self.alert_mask,
7551                    AlertKind::DhtGetResult {
7552                        target,
7553                        value: value.clone(),
7554                    },
7555                );
7556                Ok(value)
7557            }
7558            Err(e) => {
7559                post_alert(
7560                    &self.alert_tx,
7561                    &self.alert_mask,
7562                    AlertKind::DhtItemError {
7563                        target,
7564                        message: e.to_string(),
7565                    },
7566                );
7567                Err(crate::Error::Dht(e))
7568            }
7569        }
7570    }
7571
7572    async fn handle_dht_put_mutable(
7573        &self,
7574        keypair_bytes: [u8; 32],
7575        value: Vec<u8>,
7576        seq: i64,
7577        salt: Vec<u8>,
7578    ) -> crate::Result<Id20> {
7579        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7580        match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7581            Ok(target) => {
7582                post_alert(
7583                    &self.alert_tx,
7584                    &self.alert_mask,
7585                    AlertKind::DhtMutablePutComplete { target, seq },
7586                );
7587                Ok(target)
7588            }
7589            Err(e) => {
7590                post_alert(
7591                    &self.alert_tx,
7592                    &self.alert_mask,
7593                    AlertKind::DhtItemError {
7594                        target: Id20::from([0u8; 20]),
7595                        message: e.to_string(),
7596                    },
7597                );
7598                Err(crate::Error::Dht(e))
7599            }
7600        }
7601    }
7602
7603    async fn handle_dht_get_mutable(
7604        &self,
7605        public_key: [u8; 32],
7606        salt: Vec<u8>,
7607    ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7608        let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7609        let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7610        match dht.get_mutable(public_key, salt).await {
7611            Ok(result) => {
7612                let (value, seq) = match &result {
7613                    Some((v, s)) => (Some(v.clone()), Some(*s)),
7614                    None => (None, None),
7615                };
7616                post_alert(
7617                    &self.alert_tx,
7618                    &self.alert_mask,
7619                    AlertKind::DhtMutableGetResult {
7620                        target,
7621                        value,
7622                        seq,
7623                        public_key,
7624                    },
7625                );
7626                Ok(result)
7627            }
7628            Err(e) => {
7629                post_alert(
7630                    &self.alert_tx,
7631                    &self.alert_mask,
7632                    AlertKind::DhtItemError {
7633                        target,
7634                        message: e.to_string(),
7635                    },
7636                );
7637                Err(crate::Error::Dht(e))
7638            }
7639        }
7640    }
7641
7642    async fn shutdown_all(&mut self) {
7643        // Save resume files before draining torrents (M161 Phase 5).
7644        let save_count = self.save_dirty_resume_files().await;
7645        if save_count > 0 {
7646            info!(save_count, "saved resume files on shutdown");
7647        }
7648
7649        for (info_hash, entry) in self.torrents.drain() {
7650            debug!(%info_hash, "shutting down torrent");
7651            let _ = entry.handle.shutdown().await;
7652        }
7653        if let Some(ref dht) = self.dht_v4 {
7654            let _ = dht.shutdown().await;
7655        }
7656        if let Some(ref dht) = self.dht_v6 {
7657            let _ = dht.shutdown().await;
7658        }
7659        if let Some(ref nat) = self.nat {
7660            nat.shutdown().await;
7661        }
7662        if let Some(ref lsd) = self.lsd {
7663            lsd.shutdown().await;
7664        }
7665        if let Some(ref socket) = self.utp_socket
7666            && let Err(e) = socket.shutdown().await
7667        {
7668            debug!(error = %e, "uTP socket shutdown error");
7669        }
7670        if let Some(ref socket) = self.utp_socket_v6
7671            && let Err(e) = socket.shutdown().await
7672        {
7673            debug!(error = %e, "uTP v6 socket shutdown error");
7674        }
7675        self.disk_manager.shutdown().await;
7676    }
7677}
7678
7679/// Helper to receive NAT events from an optional receiver.
7680/// Returns `pending` if no receiver is available, so the `select!` branch is skipped.
7681async fn recv_nat_event(
7682    rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7683) -> irontide_nat::NatEvent {
7684    match rx {
7685        Some(r) => match r.recv().await {
7686            Some(event) => event,
7687            None => std::future::pending().await,
7688        },
7689        None => std::future::pending().await,
7690    }
7691}
7692
7693/// Receive from an optional DHT IP consensus channel, pending forever if absent.
7694async fn recv_dht_ip(
7695    rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7696) -> Option<std::net::IpAddr> {
7697    match rx {
7698        Some(r) => r.recv().await,
7699        None => std::future::pending().await,
7700    }
7701}
7702
7703/// Synthesize a minimal `TorrentMetaV1` from a `TorrentMetaV2` for session compatibility.
7704///
7705/// The session engine uses v1 structures internally (info hash as Id20, `InfoDict` for
7706/// piece hashing, etc.). For v2-only torrents, we create a "virtual" v1 representation
7707/// with the truncated SHA-256 hash as the `info_hash`.
7708/// M223 — off-actor add-torrent prep phase. Runs in a `tokio::spawn`'d
7709/// task launched by `SessionActor::try_spawn_add_torrent` so concurrent
7710/// adds do not serialise the actor's command queue. Performs the heavy
7711/// work that previously caused super-linear handler-cost growth in the
7712/// parallel-7 POST tail:
7713/// - synthesise / clone v1 metadata
7714/// - create the storage backend (filesystem or memory)
7715/// - `disk_manager.register_torrent` (async ack from disk actor)
7716/// - `TorrentHandle::from_torrent` (spawns the `TorrentActor`)
7717///
7718/// Returns `PreparedAddTorrent` on success — the `SessionActor` commit
7719/// arm then inserts it into `self.torrents` + queue position + alert +
7720/// LSD. Failures propagate untouched so the commit arm can reply with
7721/// the original error.
7722async fn prepare_add_torrent_off_actor(
7723    bundle: AddTorrentPrepBundle,
7724) -> crate::Result<PreparedAddTorrent> {
7725    let AddTorrentPrepBundle {
7726        torrent_meta,
7727        storage_override,
7728        torrent_config,
7729        disk_manager,
7730        dht_v4_broadcast,
7731        dht_v6_broadcast,
7732        global_up,
7733        global_down,
7734        slot_tuner,
7735        alert_tx,
7736        alert_mask,
7737        utp_socket,
7738        utp_socket_v6,
7739        ban_manager,
7740        ip_filter,
7741        plugins,
7742        sam_session,
7743        ssl_manager,
7744        factory,
7745        hash_pool,
7746        counters,
7747        m170_post,
7748        auto_managed,
7749    } = bundle;
7750
7751    let version = torrent_meta.version();
7752    let meta_v2 = torrent_meta.as_v2().cloned();
7753
7754    // For v2-only torrents, synthesize a minimal v1 metadata wrapper.
7755    // The session uses info_hash (Id20) as the primary key, so we use
7756    // the SHA-256 truncated to 20 bytes (per BEP 52 tracker/DHT compat).
7757    let meta = if let Some(v1) = torrent_meta.as_v1() {
7758        v1.clone()
7759    } else {
7760        let v2 = torrent_meta.as_v2().unwrap();
7761        synthesize_v1_from_v2(v2)
7762    };
7763    let info_hash = meta.info_hash;
7764    let is_private = meta.info.private == Some(1);
7765
7766    // Create or use provided storage, then register with disk manager
7767    let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7768        s
7769    } else {
7770        let lengths = Lengths::new(
7771            meta.info.total_length(),
7772            meta.info.piece_length,
7773            DEFAULT_CHUNK_SIZE,
7774        );
7775        // M252/ER5: lay out the logical file list per the baked
7776        // `content_layout` before any on-disk path derives from it.
7777        let files = torrent_config
7778            .content_layout
7779            .apply_to_files(meta.info.files());
7780        let file_paths: Vec<PathBuf> = files
7781            .iter()
7782            .map(|f| f.path.iter().collect::<PathBuf>())
7783            .collect();
7784        let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7785        let prealloc_mode = torrent_config.preallocate_mode;
7786        match irontide_storage::FilesystemStorage::new(
7787            &torrent_config.download_dir,
7788            file_paths,
7789            file_lengths,
7790            lengths.clone(),
7791            None,
7792            prealloc_mode,
7793            torrent_config.filesystem_direct_io,
7794        ) {
7795            Ok(s) => Arc::new(s),
7796            Err(e) => {
7797                warn!("failed to create filesystem storage: {e}, falling back to memory");
7798                Arc::new(irontide_storage::MemoryStorage::new(lengths))
7799            }
7800        }
7801    };
7802    let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7803
7804    let handle = TorrentHandle::from_torrent(
7805        meta.clone(),
7806        version,
7807        meta_v2,
7808        disk_handle,
7809        disk_manager,
7810        torrent_config,
7811        dht_v4_broadcast.subscribe(),
7812        dht_v6_broadcast.subscribe(),
7813        global_up,
7814        global_down,
7815        slot_tuner,
7816        alert_tx.clone(),
7817        Arc::clone(&alert_mask),
7818        utp_socket,
7819        utp_socket_v6,
7820        ban_manager,
7821        ip_filter,
7822        plugins,
7823        sam_session,
7824        ssl_manager,
7825        factory,
7826        Some(hash_pool),
7827        counters,
7828    )
7829    .await?;
7830
7831    // M223 — post `TorrentAdded` here (sync, in the prep task) rather
7832    // than in `commit_add_torrent` (on the session actor) so the alert
7833    // ordering invariant survives spawn-per-add. `TorrentHandle::from_torrent`
7834    // spawns the `TorrentActor` internally but doesn't yield between
7835    // the spawn and its return, so this post races only with the
7836    // following `commit_tx.send.await` yield. The `TorrentActor`'s
7837    // first alert (`StateChanged → Checking` from `verify_existing_pieces`)
7838    // therefore fires AFTER this `TorrentAdded`.
7839    //
7840    // **Limitation**: parallel adds with the same info-hash both reach
7841    // here and both post `TorrentAdded`, even though the commit re-check
7842    // will fail one of them with `DuplicateTorrent`. The losing
7843    // `TorrentActor` shuts down cleanly when its `TorrentHandle` is
7844    // dropped in `commit_add_torrent`, but its `TorrentAdded` is a
7845    // false positive. Production callers do not parallelise same-hash
7846    // adds; accepted edge case.
7847    post_alert(
7848        &alert_tx,
7849        &alert_mask,
7850        AlertKind::TorrentAdded {
7851            info_hash,
7852            name: meta.info.name.clone(),
7853        },
7854    );
7855    Ok(PreparedAddTorrent {
7856        handle,
7857        info_hash,
7858        is_private,
7859        m170_post,
7860        auto_managed,
7861    })
7862}
7863
7864/// A per-torrent snapshot captured cheaply on the `SessionActor` so the actual
7865/// resume-file write can run OFF the recv loop (audit Tier-2 L1, M241).
7866/// `TorrentHandle` is `#[derive(Clone)]` over a channel sender, so cloning is
7867/// cheap; `queue_position`/`auto_managed` are copied as the `i64` the resume
7868/// format stores (matching the old inline conversion).
7869struct ResumeSaveJob {
7870    info_hash: Id20,
7871    handle: TorrentHandle,
7872    queue_position: i64,
7873    auto_managed: i64,
7874}
7875
7876/// Write resume files for every dirty torrent in `jobs`. Touches no
7877/// `SessionActor` state — only the cloned per-torrent handles + the snapshotted
7878/// queue metadata — so it is safe to `tokio::spawn` off the recv loop. Returns
7879/// the number of files successfully written. Per-torrent failures `warn` and
7880/// continue: one stopped/removed torrent must never abort the whole batch
7881/// (eng-review F2).
7882async fn run_resume_save_jobs(resume_dir: std::path::PathBuf, jobs: Vec<ResumeSaveJob>) -> usize {
7883    // F3 (eng-review): create_dir_all is blocking — run it on the blocking pool
7884    // so it never occupies an async worker thread.
7885    let torrents_dir = resume_dir.join("torrents");
7886    match tokio::task::spawn_blocking(move || std::fs::create_dir_all(&torrents_dir)).await {
7887        Ok(Ok(())) => {}
7888        Ok(Err(e)) => {
7889            warn!("failed to create resume dir: {e}");
7890            return 0;
7891        }
7892        Err(e) => {
7893            warn!("resume dir create task panicked: {e}");
7894            return 0;
7895        }
7896    }
7897
7898    let mut saved = 0usize;
7899    for job in &jobs {
7900        // F1 (M245) — atomically take resume data IFF dirty. One command turn
7901        // reads `need_save_resume`, builds the data, and clears the flag with no
7902        // `.await` between read and clear, closing the pre-M245 race where a
7903        // dirty mark set between the old `stats()` check and the separate
7904        // `clear_save_resume_flag()` was silently lost. `Ok(None)` => clean,
7905        // nothing to write. `Err(_)` => torrent shut down; warn+continue.
7906        let mut rd = match job.handle.take_resume_if_dirty().await {
7907            Ok(Some(rd)) => rd,
7908            Ok(None) => continue,
7909            Err(e) => {
7910                warn!(info_hash = %job.info_hash, "failed to take resume data: {e}");
7911                continue;
7912            }
7913        };
7914        rd.queue_position = job.queue_position;
7915        rd.auto_managed = job.auto_managed;
7916
7917        // From here the flag is already cleared. Any failure before the write
7918        // lands must re-arm it (D3) so the torrent is retried next cycle rather
7919        // than silently dropped. `resume_save_lock` serializes whole batches, so
7920        // no save-sequence number is needed to order concurrent writers.
7921        let bytes = match crate::resume_file::serialize_resume(&rd) {
7922            Ok(b) => b,
7923            Err(e) => {
7924                warn!(info_hash = %job.info_hash, "failed to serialize resume data: {e}");
7925                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7926                continue;
7927            }
7928        };
7929
7930        // Atomic write — blocking, so run it on the blocking pool (F3).
7931        let path = crate::resume_file::resume_file_path(&resume_dir, &job.info_hash);
7932        let write_res =
7933            tokio::task::spawn_blocking(move || crate::resume_file::atomic_write(&path, &bytes))
7934                .await;
7935        match write_res {
7936            Ok(Ok(())) => {}
7937            Ok(Err(e)) => {
7938                warn!(info_hash = %job.info_hash, "failed to write resume file: {e}");
7939                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7940                continue;
7941            }
7942            Err(e) => {
7943                warn!(info_hash = %job.info_hash, "resume write task panicked: {e}");
7944                redirty_after_failed_save(&job.handle, &job.info_hash).await;
7945                continue;
7946            }
7947        }
7948
7949        saved = saved.saturating_add(1);
7950    }
7951    saved
7952}
7953
7954/// Re-arm a torrent's `need_save_resume` flag after [`run_resume_save_jobs`]
7955/// already cleared it (via `take_resume_if_dirty`) but the subsequent serialize
7956/// or disk write failed (M245 F1, eng-review D3). Without this the dirty state
7957/// would be lost and the torrent would not be retried until it next mutates.
7958async fn redirty_after_failed_save(handle: &TorrentHandle, info_hash: &Id20) {
7959    if let Err(e) = handle.mark_resume_dirty().await {
7960        warn!(info_hash = %info_hash, "failed to re-mark resume dirty after save failure: {e}");
7961    }
7962}
7963
7964fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7965    use irontide_core::{FileEntry, InfoDict};
7966
7967    let info_hash = v2.info_hashes.best_v1();
7968
7969    // Build file entries from v2 file tree
7970    let v2_files = v2.info.files();
7971    let file_entries: Vec<FileEntry> = v2_files
7972        .iter()
7973        .map(|f| FileEntry {
7974            length: f.attr.length,
7975            path: f.path.clone(),
7976            attr: None,
7977            mtime: None,
7978            symlink_path: None,
7979        })
7980        .collect();
7981
7982    // v2-only torrents have no v1 piece hashes — use placeholder pieces field.
7983    // Verification is done via v2 Merkle trees, not v1 SHA-1 hashes.
7984    let num_pieces = v2.info.num_pieces() as usize;
7985    let pieces = vec![0u8; num_pieces * 20];
7986
7987    let info = InfoDict {
7988        name: v2.info.name.clone(),
7989        piece_length: v2.info.piece_length,
7990        pieces,
7991        length: if file_entries.len() == 1 {
7992            Some(file_entries[0].length)
7993        } else {
7994            None
7995        },
7996        files: if file_entries.len() > 1 {
7997            Some(file_entries)
7998        } else {
7999            None
8000        },
8001        private: None,
8002        source: None,
8003        ssl_cert: v2.ssl_cert.clone(),
8004        similar: Vec::new(),
8005        collections: Vec::new(),
8006    };
8007
8008    irontide_core::TorrentMetaV1 {
8009        info_hash,
8010        announce: v2.announce.clone(),
8011        announce_list: v2.announce_list.clone(),
8012        comment: v2.comment.clone(),
8013        created_by: v2.created_by.clone(),
8014        creation_date: v2.creation_date,
8015        info,
8016        info_bytes: None,
8017        url_list: Vec::new(),
8018        httpseeds: Vec::new(),
8019        ssl_cert: v2.ssl_cert.clone(),
8020    }
8021}
8022
8023#[cfg(test)]
8024mod tests {
8025    use super::*;
8026    use crate::types::TorrentState;
8027    use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
8028    use irontide_storage::MemoryStorage;
8029    use std::time::Duration;
8030
8031    // === M251 — compute_external_address decision table (plan D2) ===
8032
8033    #[test]
8034    fn m251_external_address_none_without_ip() {
8035        assert_eq!(
8036            SessionActor::compute_external_address(None, Some(6881), 6881),
8037            None
8038        );
8039    }
8040
8041    #[test]
8042    fn m251_external_address_prefers_mapped_port() {
8043        let ip = "203.0.113.7".parse().unwrap();
8044        assert_eq!(
8045            SessionActor::compute_external_address(Some(ip), Some(51413), 6881),
8046            Some("203.0.113.7:51413".parse().unwrap())
8047        );
8048    }
8049
8050    #[test]
8051    fn m251_external_address_falls_back_to_listen_port() {
8052        let ip = "203.0.113.7".parse().unwrap();
8053        assert_eq!(
8054            SessionActor::compute_external_address(Some(ip), None, 6881),
8055            Some("203.0.113.7:6881".parse().unwrap())
8056        );
8057    }
8058
8059    #[test]
8060    fn m251_external_address_none_for_ephemeral_unmapped() {
8061        let ip = "203.0.113.7".parse().unwrap();
8062        assert_eq!(
8063            SessionActor::compute_external_address(Some(ip), None, 0),
8064            None
8065        );
8066    }
8067
8068    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
8069        use serde::Serialize;
8070
8071        #[derive(Serialize)]
8072        struct Info<'a> {
8073            length: u64,
8074            name: &'a str,
8075            #[serde(rename = "piece length")]
8076            piece_length: u64,
8077            #[serde(with = "serde_bytes")]
8078            pieces: &'a [u8],
8079        }
8080
8081        #[derive(Serialize)]
8082        struct Torrent<'a> {
8083            info: Info<'a>,
8084        }
8085
8086        let mut pieces = Vec::new();
8087        let mut offset = 0;
8088        while offset < data.len() {
8089            let end = (offset + piece_length as usize).min(data.len());
8090            let hash = irontide_core::sha1(&data[offset..end]);
8091            pieces.extend_from_slice(hash.as_bytes());
8092            offset = end;
8093        }
8094
8095        let t = Torrent {
8096            info: Info {
8097                length: data.len() as u64,
8098                name: "test",
8099                piece_length,
8100                pieces: &pieces,
8101            },
8102        };
8103
8104        let bytes = irontide_bencode::to_bytes(&t).unwrap();
8105        torrent_from_bytes(&bytes).unwrap()
8106    }
8107
8108    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
8109        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
8110        Arc::new(MemoryStorage::new(lengths))
8111    }
8112
8113    static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8114
8115    fn test_settings() -> Settings {
8116        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8117        let pid = std::process::id();
8118        let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
8119        let resume_dir =
8120            std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
8121        let _ = std::fs::remove_dir_all(&dl_dir);
8122        let _ = std::fs::remove_dir_all(&resume_dir);
8123        let _ = std::fs::create_dir_all(&dl_dir);
8124
8125        Settings {
8126            listen_port: 0,
8127            download_dir: dl_dir,
8128            resume_data_dir: Some(resume_dir),
8129            max_torrents: 10,
8130            enable_dht: false,
8131            enable_pex: false,
8132            enable_lsd: false,
8133            enable_fast_extension: false,
8134            enable_utp: false,
8135            enable_upnp: false,
8136            enable_natpmp: false,
8137            enable_ipv6: false,
8138            alert_channel_size: 64,
8139            disk_io_threads: 2,
8140            disk_cache_size: 1024 * 1024,
8141            ..Settings::default()
8142        }
8143    }
8144
8145    // ---- Test 1: Start and shutdown ----
8146
8147    #[tokio::test]
8148    async fn session_start_and_shutdown() {
8149        let session = SessionHandle::start(test_settings()).await.unwrap();
8150        let stats = session.session_stats().await.unwrap();
8151        assert_eq!(stats.active_torrents, 0);
8152        session.shutdown().await.unwrap();
8153    }
8154
8155    #[tokio::test]
8156    async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
8157        let session = SessionHandle::start(test_settings()).await.unwrap();
8158        let bogus = Id20([0u8; 20]);
8159        let result = session.peer_unchoke_durations(bogus).await.unwrap();
8160        assert!(
8161            result.is_none(),
8162            "missing torrent must yield None, not an empty map"
8163        );
8164        session.shutdown().await.unwrap();
8165    }
8166
8167    #[tokio::test]
8168    async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
8169        let session = SessionHandle::start(test_settings()).await.unwrap();
8170        let data = vec![0xAB; 16384];
8171        let meta = make_test_torrent(&data, 16384);
8172        let storage = make_storage(&data, 16384);
8173        let info_hash = session
8174            .add_torrent_with_meta(meta.into(), Some(storage))
8175            .await
8176            .unwrap();
8177        let result = session
8178            .peer_unchoke_durations(info_hash)
8179            .await
8180            .unwrap()
8181            .expect("known torrent must yield Some, even with no peers");
8182        assert!(
8183            result.is_empty(),
8184            "fresh torrent with no peers has no unchoke history"
8185        );
8186        session.shutdown().await.unwrap();
8187    }
8188
8189    // ---- Test 2: Add and list torrent ----
8190
8191    #[tokio::test]
8192    async fn add_and_list_torrent() {
8193        let session = SessionHandle::start(test_settings()).await.unwrap();
8194        let data = vec![0xAB; 16384];
8195        let meta = make_test_torrent(&data, 16384);
8196        let expected_hash = meta.info_hash;
8197
8198        let storage = make_storage(&data, 16384);
8199        let info_hash = session
8200            .add_torrent_with_meta(meta.into(), Some(storage))
8201            .await
8202            .unwrap();
8203        assert_eq!(info_hash, expected_hash);
8204
8205        let list = session.list_torrents().await.unwrap();
8206        assert_eq!(list.len(), 1);
8207        assert!(list.contains(&info_hash));
8208
8209        session.shutdown().await.unwrap();
8210    }
8211
8212    // ---- Test 3: Remove torrent ----
8213
8214    #[tokio::test]
8215    async fn remove_torrent() {
8216        let session = SessionHandle::start(test_settings()).await.unwrap();
8217        let data = vec![0xAB; 16384];
8218        let meta = make_test_torrent(&data, 16384);
8219        let storage = make_storage(&data, 16384);
8220
8221        let info_hash = session
8222            .add_torrent_with_meta(meta.into(), Some(storage))
8223            .await
8224            .unwrap();
8225        session.remove_torrent(info_hash).await.unwrap();
8226
8227        tokio::time::sleep(Duration::from_millis(50)).await;
8228
8229        let list = session.list_torrents().await.unwrap();
8230        assert!(list.is_empty());
8231
8232        session.shutdown().await.unwrap();
8233    }
8234
8235    // ---- Test 4: Duplicate rejection ----
8236
8237    #[tokio::test]
8238    async fn duplicate_torrent_rejected() {
8239        let session = SessionHandle::start(test_settings()).await.unwrap();
8240        let data = vec![0xAB; 16384];
8241        let meta = make_test_torrent(&data, 16384);
8242        let storage1 = make_storage(&data, 16384);
8243        let storage2 = make_storage(&data, 16384);
8244
8245        session
8246            .add_torrent_with_meta(meta.clone().into(), Some(storage1))
8247            .await
8248            .unwrap();
8249        let result = session
8250            .add_torrent_with_meta(meta.into(), Some(storage2))
8251            .await;
8252        assert!(result.is_err());
8253        assert!(result.unwrap_err().to_string().contains("duplicate"));
8254
8255        session.shutdown().await.unwrap();
8256    }
8257
8258    // ---- Test 5: Max capacity ----
8259
8260    #[tokio::test]
8261    async fn session_at_capacity() {
8262        let mut config = test_settings();
8263        config.max_torrents = 1;
8264        let session = SessionHandle::start(config).await.unwrap();
8265
8266        let data1 = vec![0xAA; 16384];
8267        let meta1 = make_test_torrent(&data1, 16384);
8268        let storage1 = make_storage(&data1, 16384);
8269        session
8270            .add_torrent_with_meta(meta1.into(), Some(storage1))
8271            .await
8272            .unwrap();
8273
8274        let data2 = vec![0xBB; 16384];
8275        let meta2 = make_test_torrent(&data2, 16384);
8276        let storage2 = make_storage(&data2, 16384);
8277        let result = session
8278            .add_torrent_with_meta(meta2.into(), Some(storage2))
8279            .await;
8280        assert!(result.is_err());
8281        assert!(result.unwrap_err().to_string().contains("capacity"));
8282
8283        session.shutdown().await.unwrap();
8284    }
8285
8286    // ---- Test 6: Torrent stats ----
8287
8288    #[tokio::test]
8289    async fn torrent_stats_via_session() {
8290        let session = SessionHandle::start(test_settings()).await.unwrap();
8291        let data = vec![0xAB; 32768];
8292        let meta = make_test_torrent(&data, 16384);
8293        let storage = make_storage(&data, 16384);
8294
8295        let info_hash = session
8296            .add_torrent_with_meta(meta.into(), Some(storage))
8297            .await
8298            .unwrap();
8299        let stats = session.torrent_stats(info_hash).await.unwrap();
8300        assert_eq!(stats.state, TorrentState::Downloading);
8301        assert_eq!(stats.pieces_total, 2);
8302
8303        session.shutdown().await.unwrap();
8304    }
8305
8306    // ---- Test 7: Torrent info ----
8307
8308    #[tokio::test]
8309    async fn torrent_info_via_session() {
8310        let session = SessionHandle::start(test_settings()).await.unwrap();
8311        let data = vec![0xAB; 32768];
8312        let meta = make_test_torrent(&data, 16384);
8313        let storage = make_storage(&data, 16384);
8314
8315        let info_hash = session
8316            .add_torrent_with_meta(meta.into(), Some(storage))
8317            .await
8318            .unwrap();
8319        let info = session.torrent_info(info_hash).await.unwrap();
8320        assert_eq!(info.info_hash, info_hash);
8321        assert_eq!(info.name, "test");
8322        assert_eq!(info.total_length, 32768);
8323        assert_eq!(info.num_pieces, 2);
8324        assert!(!info.private);
8325        assert_eq!(info.files.len(), 1);
8326        assert_eq!(info.files[0].length, 32768);
8327
8328        session.shutdown().await.unwrap();
8329    }
8330
8331    // ---- Test 8: Pause/resume via session ----
8332
8333    #[tokio::test]
8334    async fn pause_resume_via_session() {
8335        let session = SessionHandle::start(test_settings()).await.unwrap();
8336        let data = vec![0xAB; 16384];
8337        let meta = make_test_torrent(&data, 16384);
8338        let storage = make_storage(&data, 16384);
8339
8340        let info_hash = session
8341            .add_torrent_with_meta(meta.into(), Some(storage))
8342            .await
8343            .unwrap();
8344
8345        session.pause_torrent(info_hash).await.unwrap();
8346        tokio::time::sleep(Duration::from_millis(50)).await;
8347        let stats = session.torrent_stats(info_hash).await.unwrap();
8348        assert_eq!(stats.state, TorrentState::Paused);
8349
8350        session.resume_torrent(info_hash).await.unwrap();
8351        tokio::time::sleep(Duration::from_millis(50)).await;
8352        let stats = session.torrent_stats(info_hash).await.unwrap();
8353        assert_eq!(stats.state, TorrentState::Downloading);
8354
8355        session.shutdown().await.unwrap();
8356    }
8357
8358    // ---- Test 9: Not-found errors ----
8359
8360    #[tokio::test]
8361    async fn not_found_errors() {
8362        let session = SessionHandle::start(test_settings()).await.unwrap();
8363        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8364
8365        assert!(session.torrent_stats(fake_hash).await.is_err());
8366        assert!(session.torrent_info(fake_hash).await.is_err());
8367        assert!(session.pause_torrent(fake_hash).await.is_err());
8368        assert!(session.resume_torrent(fake_hash).await.is_err());
8369        assert!(session.remove_torrent(fake_hash).await.is_err());
8370
8371        session.shutdown().await.unwrap();
8372    }
8373
8374    // ---- Test 10: Session stats ----
8375
8376    #[tokio::test]
8377    async fn session_stats_aggregate() {
8378        let session = SessionHandle::start(test_settings()).await.unwrap();
8379
8380        let data1 = vec![0xAA; 16384];
8381        let meta1 = make_test_torrent(&data1, 16384);
8382        let storage1 = make_storage(&data1, 16384);
8383        session
8384            .add_torrent_with_meta(meta1.into(), Some(storage1))
8385            .await
8386            .unwrap();
8387
8388        let data2 = vec![0xBB; 16384];
8389        let meta2 = make_test_torrent(&data2, 16384);
8390        let storage2 = make_storage(&data2, 16384);
8391        session
8392            .add_torrent_with_meta(meta2.into(), Some(storage2))
8393            .await
8394            .unwrap();
8395
8396        let stats = session.session_stats().await.unwrap();
8397        assert_eq!(stats.active_torrents, 2);
8398
8399        session.shutdown().await.unwrap();
8400    }
8401
8402    // ---- Test 11: Add magnet and list ----
8403
8404    #[tokio::test]
8405    async fn add_magnet_and_list() {
8406        use irontide_core::Magnet;
8407
8408        let session = SessionHandle::start(test_settings()).await.unwrap();
8409        let magnet = Magnet {
8410            info_hashes: irontide_core::InfoHashes::v1_only(
8411                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
8412            ),
8413            display_name: Some("test-magnet".into()),
8414            trackers: vec![],
8415            peers: vec![],
8416            selected_files: None,
8417        };
8418        let expected_hash = magnet.info_hash();
8419
8420        let info_hash = session.add_magnet(magnet).await.unwrap();
8421        assert_eq!(info_hash, expected_hash);
8422
8423        let list = session.list_torrents().await.unwrap();
8424        assert_eq!(list.len(), 1);
8425        assert!(list.contains(&info_hash));
8426
8427        // torrent_info should fail with MetadataNotReady
8428        let err = session.torrent_info(info_hash).await.unwrap_err();
8429        assert!(err.to_string().contains("metadata not yet available"));
8430
8431        session.shutdown().await.unwrap();
8432    }
8433
8434    // ---- Test 12: Duplicate magnet rejected ----
8435
8436    #[tokio::test]
8437    async fn add_magnet_duplicate_rejected() {
8438        use irontide_core::Magnet;
8439
8440        let session = SessionHandle::start(test_settings()).await.unwrap();
8441        let magnet = Magnet {
8442            info_hashes: irontide_core::InfoHashes::v1_only(
8443                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
8444            ),
8445            display_name: Some("test-magnet".into()),
8446            trackers: vec![],
8447            peers: vec![],
8448            selected_files: None,
8449        };
8450
8451        session.add_magnet(magnet.clone()).await.unwrap();
8452        let result = session.add_magnet(magnet).await;
8453        assert!(result.is_err());
8454        assert!(result.unwrap_err().to_string().contains("duplicate"));
8455
8456        session.shutdown().await.unwrap();
8457    }
8458
8459    // ---- Test 13: Session with LSD enabled ----
8460
8461    #[tokio::test]
8462    async fn session_with_lsd_enabled() {
8463        use irontide_core::Magnet;
8464
8465        // LSD may fail to bind port 6771 — session should still start
8466        let mut config = test_settings();
8467        config.enable_lsd = true;
8468
8469        let session = SessionHandle::start(config).await.unwrap();
8470
8471        // Add a torrent (triggers LSD announce if available)
8472        let data = vec![0xAB; 16384];
8473        let meta = make_test_torrent(&data, 16384);
8474        let storage = make_storage(&data, 16384);
8475        session
8476            .add_torrent_with_meta(meta.into(), Some(storage))
8477            .await
8478            .unwrap();
8479
8480        // Add a magnet (also triggers LSD announce)
8481        let magnet = Magnet {
8482            info_hashes: irontide_core::InfoHashes::v1_only(
8483                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
8484            ),
8485            display_name: Some("lsd-test".into()),
8486            trackers: vec![],
8487            peers: vec![],
8488            selected_files: None,
8489        };
8490        session.add_magnet(magnet).await.unwrap();
8491
8492        let list = session.list_torrents().await.unwrap();
8493        assert_eq!(list.len(), 2);
8494
8495        session.shutdown().await.unwrap();
8496    }
8497
8498    // ---- Test: v2-only torrent addition ----
8499
8500    #[tokio::test]
8501    async fn add_v2_only_torrent() {
8502        use irontide_bencode::BencodeValue;
8503        use std::collections::BTreeMap;
8504
8505        let session = SessionHandle::start(test_settings()).await.unwrap();
8506
8507        // Build a minimal v2-only torrent
8508        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8509        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
8510        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8511        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
8512        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8513        ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
8514
8515        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8516        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
8517        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
8518        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
8519        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
8520
8521        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
8522        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
8523
8524        let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
8525        let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
8526        assert!(meta.is_v2());
8527
8528        // This should NOT return an error now (v2-only is supported)
8529        let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
8530        let list = session.list_torrents().await.unwrap();
8531        assert!(list.contains(&info_hash));
8532
8533        session.shutdown().await.unwrap();
8534    }
8535
8536    // ---- Test 14: Save torrent resume data via session ----
8537
8538    #[tokio::test]
8539    async fn save_torrent_resume_data_via_session() {
8540        let session = SessionHandle::start(test_settings()).await.unwrap();
8541        let data = vec![0xAB; 32768];
8542        let meta = make_test_torrent(&data, 16384);
8543        let info_hash = meta.info_hash;
8544        let storage = make_storage(&data, 16384);
8545        session
8546            .add_torrent_with_meta(meta.into(), Some(storage))
8547            .await
8548            .unwrap();
8549
8550        let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
8551        assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
8552        assert_eq!(rd.name, "test");
8553        assert_eq!(rd.file_format, "libtorrent resume file");
8554        assert_eq!(rd.file_version, 1);
8555        assert!(!rd.pieces.is_empty());
8556        assert_eq!(rd.paused, 0);
8557
8558        session.shutdown().await.unwrap();
8559    }
8560
8561    // ---- Test 15: Save session state captures all torrents ----
8562
8563    #[tokio::test]
8564    async fn save_session_state_captures_all_torrents() {
8565        let session = SessionHandle::start(test_settings()).await.unwrap();
8566
8567        let data1 = vec![0xAA; 16384];
8568        let meta1 = make_test_torrent(&data1, 16384);
8569        let storage1 = make_storage(&data1, 16384);
8570        session
8571            .add_torrent_with_meta(meta1.into(), Some(storage1))
8572            .await
8573            .unwrap();
8574
8575        let data2 = vec![0xBB; 16384];
8576        let meta2 = make_test_torrent(&data2, 16384);
8577        let storage2 = make_storage(&data2, 16384);
8578        session
8579            .add_torrent_with_meta(meta2.into(), Some(storage2))
8580            .await
8581            .unwrap();
8582
8583        let state = session.save_session_state().await.unwrap();
8584        assert_eq!(state.torrents.len(), 2);
8585
8586        for rd in &state.torrents {
8587            assert_eq!(rd.file_format, "libtorrent resume file");
8588            assert_eq!(rd.info_hash.len(), 20);
8589        }
8590
8591        session.shutdown().await.unwrap();
8592    }
8593
8594    // ---- Test 16: Save resume data not found ----
8595
8596    #[tokio::test]
8597    async fn save_resume_data_not_found() {
8598        let session = SessionHandle::start(test_settings()).await.unwrap();
8599        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8600        let result = session.save_torrent_resume_data(fake_hash).await;
8601        assert!(result.is_err());
8602        assert!(result.unwrap_err().to_string().contains("not found"));
8603        session.shutdown().await.unwrap();
8604    }
8605
8606    // ---- Test 17: Subscribe receives TorrentAdded alert ----
8607
8608    #[tokio::test]
8609    async fn subscribe_receives_torrent_added_alert() {
8610        use crate::alert::AlertKind;
8611
8612        let session = SessionHandle::start(test_settings()).await.unwrap();
8613        let mut alerts = session.subscribe();
8614
8615        let data = vec![0xAB; 16384];
8616        let meta = make_test_torrent(&data, 16384);
8617        let storage = make_storage(&data, 16384);
8618        let _info_hash = session
8619            .add_torrent_with_meta(meta.into(), Some(storage))
8620            .await
8621            .unwrap();
8622
8623        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8624            .await
8625            .unwrap()
8626            .unwrap();
8627        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8628        session.shutdown().await.unwrap();
8629    }
8630
8631    // ---- Test 18: Subscribe receives TorrentRemoved alert ----
8632
8633    #[tokio::test]
8634    async fn subscribe_receives_torrent_removed_alert() {
8635        use crate::alert::AlertKind;
8636        use crate::types::TorrentState;
8637
8638        let session = SessionHandle::start(test_settings()).await.unwrap();
8639        let mut alerts = session.subscribe();
8640
8641        let data = vec![0xAB; 16384];
8642        let meta = make_test_torrent(&data, 16384);
8643        let storage = make_storage(&data, 16384);
8644        let info_hash = session
8645            .add_torrent_with_meta(meta.into(), Some(storage))
8646            .await
8647            .unwrap();
8648
8649        // Drain TorrentAdded and any checking alerts
8650        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8651            if matches!(
8652                a.kind,
8653                AlertKind::StateChanged {
8654                    new_state: TorrentState::Downloading,
8655                    ..
8656                }
8657            ) {
8658                break;
8659            }
8660        }
8661
8662        session.remove_torrent(info_hash).await.unwrap();
8663
8664        // Find TorrentRemoved (skip any interleaved alerts)
8665        loop {
8666            let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8667                .await
8668                .unwrap()
8669                .unwrap();
8670            if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8671                break;
8672            }
8673        }
8674        session.shutdown().await.unwrap();
8675    }
8676
8677    // ---- Test 19: Multiple subscribers each receive alerts ----
8678
8679    #[tokio::test]
8680    async fn multiple_subscribers_each_receive_alerts() {
8681        use crate::alert::AlertKind;
8682
8683        let session = SessionHandle::start(test_settings()).await.unwrap();
8684        let mut sub1 = session.subscribe();
8685        let mut sub2 = session.subscribe();
8686
8687        let data = vec![0xAB; 16384];
8688        let meta = make_test_torrent(&data, 16384);
8689        let storage = make_storage(&data, 16384);
8690        session
8691            .add_torrent_with_meta(meta.into(), Some(storage))
8692            .await
8693            .unwrap();
8694
8695        let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8696            .await
8697            .unwrap()
8698            .unwrap();
8699        let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8700            .await
8701            .unwrap()
8702            .unwrap();
8703
8704        assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8705        assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8706        session.shutdown().await.unwrap();
8707    }
8708
8709    // ---- Test 20: set_alert_mask filters at runtime ----
8710
8711    #[tokio::test]
8712    async fn set_alert_mask_filters_at_runtime() {
8713        use crate::alert::{AlertCategory, AlertKind};
8714
8715        let session = SessionHandle::start(test_settings()).await.unwrap();
8716        let mut alerts = session.subscribe();
8717
8718        // Start with ALL — TorrentAdded (STATUS) should arrive
8719        let data = vec![0xAB; 16384];
8720        let meta = make_test_torrent(&data, 16384);
8721        let storage = make_storage(&data, 16384);
8722        session
8723            .add_torrent_with_meta(meta.into(), Some(storage))
8724            .await
8725            .unwrap();
8726
8727        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8728            .await
8729            .unwrap()
8730            .unwrap();
8731        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8732
8733        // Drain any remaining alerts from the first torrent (StateChanged, CheckingProgress, etc.)
8734        while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8735            .await
8736            .is_ok()
8737        {}
8738
8739        // Change mask to empty — no alerts should pass
8740        session.set_alert_mask(AlertCategory::empty());
8741
8742        let data2 = vec![0xBB; 16384];
8743        let meta2 = make_test_torrent(&data2, 16384);
8744        let storage2 = make_storage(&data2, 16384);
8745        session
8746            .add_torrent_with_meta(meta2.into(), Some(storage2))
8747            .await
8748            .unwrap();
8749
8750        // Give a small window — nothing should arrive
8751        let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8752        assert!(result.is_err(), "should have timed out with empty mask");
8753
8754        // Restore STATUS — adding another torrent should arrive
8755        session.set_alert_mask(AlertCategory::STATUS);
8756
8757        let data3 = vec![0xCC; 16384];
8758        let meta3 = make_test_torrent(&data3, 16384);
8759        let storage3 = make_storage(&data3, 16384);
8760        session
8761            .add_torrent_with_meta(meta3.into(), Some(storage3))
8762            .await
8763            .unwrap();
8764
8765        let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8766            .await
8767            .unwrap()
8768            .unwrap();
8769        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8770
8771        session.shutdown().await.unwrap();
8772    }
8773
8774    // ---- Test 21: AlertStream filters per subscriber ----
8775
8776    #[tokio::test]
8777    async fn alert_stream_filters_per_subscriber() {
8778        use crate::alert::{AlertCategory, AlertKind};
8779
8780        let session = SessionHandle::start(test_settings()).await.unwrap();
8781
8782        // subscriber A: STATUS only
8783        let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8784        // subscriber B: PEER only
8785        let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8786
8787        let data = vec![0xAB; 16384];
8788        let meta = make_test_torrent(&data, 16384);
8789        let storage = make_storage(&data, 16384);
8790        session
8791            .add_torrent_with_meta(meta.into(), Some(storage))
8792            .await
8793            .unwrap();
8794
8795        // STATUS sub gets TorrentAdded
8796        let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8797            .await
8798            .unwrap()
8799            .unwrap();
8800        assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8801
8802        // PEER sub should NOT receive TorrentAdded (it's STATUS category)
8803        let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8804        assert!(
8805            result.is_err(),
8806            "PEER subscriber should not get STATUS alerts"
8807        );
8808
8809        session.shutdown().await.unwrap();
8810    }
8811
8812    // ---- Test 22: State changed tracks transitions ----
8813
8814    #[tokio::test]
8815    async fn state_changed_tracks_transitions() {
8816        use crate::alert::AlertKind;
8817
8818        let session = SessionHandle::start(test_settings()).await.unwrap();
8819        let mut alerts = session.subscribe();
8820
8821        let data = vec![0xAB; 16384];
8822        let meta = make_test_torrent(&data, 16384);
8823        let storage = make_storage(&data, 16384);
8824        let info_hash = session
8825            .add_torrent_with_meta(meta.into(), Some(storage))
8826            .await
8827            .unwrap();
8828
8829        // Drain TorrentAdded
8830        let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8831            .await
8832            .unwrap();
8833
8834        // Pause — should get StateChanged(Downloading → Paused) + TorrentPaused
8835        session.pause_torrent(info_hash).await.unwrap();
8836        tokio::time::sleep(Duration::from_millis(100)).await;
8837
8838        // Collect alerts over a short window
8839        let mut state_changes = Vec::new();
8840        let mut paused_alerts = Vec::new();
8841        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8842        {
8843            match &a.kind {
8844                AlertKind::StateChanged {
8845                    prev_state,
8846                    new_state,
8847                    ..
8848                } => {
8849                    state_changes.push((*prev_state, *new_state));
8850                }
8851                AlertKind::TorrentPaused { .. } => {
8852                    paused_alerts.push(a);
8853                }
8854                _ => {} // other alerts (PeerConnected etc)
8855            }
8856        }
8857
8858        assert!(
8859            state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8860            "expected Downloading→Paused, got: {state_changes:?}"
8861        );
8862        assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8863
8864        // Resume — should get StateChanged(Paused → Downloading) + TorrentResumed
8865        session.resume_torrent(info_hash).await.unwrap();
8866        tokio::time::sleep(Duration::from_millis(100)).await;
8867
8868        let mut resume_state_changes = Vec::new();
8869        let mut resumed_alerts = Vec::new();
8870        while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8871        {
8872            match &a.kind {
8873                AlertKind::StateChanged {
8874                    prev_state,
8875                    new_state,
8876                    ..
8877                } => {
8878                    resume_state_changes.push((*prev_state, *new_state));
8879                }
8880                AlertKind::TorrentResumed { .. } => {
8881                    resumed_alerts.push(a);
8882                }
8883                _ => {}
8884            }
8885        }
8886
8887        assert!(
8888            resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8889            "expected Paused→Downloading, got: {resume_state_changes:?}"
8890        );
8891        assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8892
8893        session.shutdown().await.unwrap();
8894    }
8895
8896    #[tokio::test]
8897    async fn session_config_creates_utp_socket() {
8898        // Start session with uTP enabled — should succeed without errors
8899        let mut config = test_settings();
8900        config.enable_utp = true;
8901        let session = SessionHandle::start(config).await.unwrap();
8902        let stats = session.session_stats().await.unwrap();
8903        assert_eq!(stats.active_torrents, 0);
8904        session.shutdown().await.unwrap();
8905    }
8906
8907    #[test]
8908    fn settings_nat_defaults() {
8909        let s = Settings::default();
8910        assert!(s.enable_upnp, "enable_upnp should default to true");
8911        assert!(s.enable_natpmp, "enable_natpmp should default to true");
8912    }
8913
8914    #[tokio::test]
8915    async fn session_with_nat_disabled() {
8916        let config = test_settings();
8917        // test_session_config already sets enable_upnp: false, enable_natpmp: false
8918        assert!(!config.enable_upnp);
8919        assert!(!config.enable_natpmp);
8920        let session = SessionHandle::start(config).await.unwrap();
8921        let stats = session.session_stats().await.unwrap();
8922        assert_eq!(stats.active_torrents, 0);
8923        session.shutdown().await.unwrap();
8924    }
8925
8926    // ---- M29: Anonymous mode, force proxy, proxy config tests ----
8927
8928    #[test]
8929    fn anonymous_mode_disables_discovery() {
8930        let mut config = test_settings();
8931        config.anonymous_mode = true;
8932        config.enable_dht = true;
8933        config.enable_lsd = true;
8934        config.enable_upnp = true;
8935        config.enable_natpmp = true;
8936
8937        // SessionHandle::start() will override these when anonymous_mode is true.
8938        // We test the enforcement logic directly here.
8939        if config.anonymous_mode {
8940            config.enable_dht = false;
8941            config.enable_lsd = false;
8942            config.enable_upnp = false;
8943            config.enable_natpmp = false;
8944        }
8945
8946        assert!(!config.enable_dht);
8947        assert!(!config.enable_lsd);
8948        assert!(!config.enable_upnp);
8949        assert!(!config.enable_natpmp);
8950    }
8951
8952    #[tokio::test]
8953    async fn anonymous_mode_session_starts_with_discovery_disabled() {
8954        let mut config = test_settings();
8955        config.anonymous_mode = true;
8956        // Even if we enable these, anonymous_mode should override
8957        config.enable_dht = true;
8958        config.enable_lsd = true;
8959
8960        let session = SessionHandle::start(config).await.unwrap();
8961        let stats = session.session_stats().await.unwrap();
8962        assert_eq!(stats.active_torrents, 0);
8963        session.shutdown().await.unwrap();
8964    }
8965
8966    #[test]
8967    fn force_proxy_requires_proxy_configured() {
8968        let mut config = test_settings();
8969        config.force_proxy = true;
8970        config.proxy = crate::proxy::ProxyConfig::default(); // no proxy
8971
8972        // Validate the config error
8973        assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8974        assert!(config.force_proxy);
8975        // This would error in SessionHandle::start()
8976    }
8977
8978    #[tokio::test]
8979    async fn force_proxy_errors_without_proxy() {
8980        let mut config = test_settings();
8981        config.force_proxy = true;
8982        // proxy_type is None by default
8983
8984        let result = SessionHandle::start(config).await;
8985        assert!(result.is_err());
8986        match result {
8987            Err(e) => assert!(
8988                e.to_string().contains("force_proxy"),
8989                "error should mention force_proxy: {e}"
8990            ),
8991            Ok(_) => panic!("expected error"),
8992        }
8993    }
8994
8995    #[test]
8996    fn force_proxy_disables_features() {
8997        let mut config = test_settings();
8998        config.force_proxy = true;
8999        config.proxy = crate::proxy::ProxyConfig {
9000            proxy_type: crate::proxy::ProxyType::Socks5,
9001            hostname: "proxy.example.com".into(),
9002            port: 1080,
9003            ..Default::default()
9004        };
9005        config.enable_dht = true;
9006        config.enable_lsd = true;
9007        config.enable_upnp = true;
9008        config.enable_natpmp = true;
9009
9010        // Simulate the enforcement from start()
9011        if config.force_proxy {
9012            config.enable_upnp = false;
9013            config.enable_natpmp = false;
9014            config.enable_dht = false;
9015            config.enable_lsd = false;
9016        }
9017
9018        assert!(!config.enable_dht);
9019        assert!(!config.enable_lsd);
9020        assert!(!config.enable_upnp);
9021        assert!(!config.enable_natpmp);
9022    }
9023
9024    #[test]
9025    fn proxy_config_round_trip() {
9026        let s = Settings {
9027            proxy: crate::proxy::ProxyConfig {
9028                proxy_type: crate::proxy::ProxyType::Socks5Password,
9029                hostname: "localhost".into(),
9030                port: 9050,
9031                username: Some("user".into()),
9032                password: Some("pass".into()),
9033                ..Default::default()
9034            },
9035            force_proxy: true,
9036            anonymous_mode: true,
9037            ..test_settings()
9038        };
9039
9040        assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
9041        assert_eq!(s.proxy.hostname, "localhost");
9042        assert_eq!(s.proxy.port, 9050);
9043        assert!(s.force_proxy);
9044        assert!(s.anonymous_mode);
9045        assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
9046    }
9047
9048    #[tokio::test]
9049    async fn apply_settings_runtime() {
9050        let session = SessionHandle::start(test_settings()).await.unwrap();
9051        let original = session.settings().await.unwrap();
9052        assert_eq!(original.max_torrents, 10);
9053
9054        let mut new = original.clone();
9055        new.max_torrents = 200;
9056        new.upload_rate_limit = 1_000_000;
9057        session.apply_settings(new).await.unwrap();
9058
9059        let updated = session.settings().await.unwrap();
9060        assert_eq!(updated.max_torrents, 200);
9061        assert_eq!(updated.upload_rate_limit, 1_000_000);
9062
9063        session.shutdown().await.unwrap();
9064    }
9065
9066    #[tokio::test]
9067    async fn apply_settings_validation_error() {
9068        let session = SessionHandle::start(test_settings()).await.unwrap();
9069
9070        // force_proxy=true without a proxy configured should fail validation
9071        let bad = Settings {
9072            force_proxy: true,
9073            ..Settings::default()
9074        };
9075        let result = session.apply_settings(bad).await;
9076        assert!(result.is_err());
9077
9078        // Original settings should be unchanged
9079        let current = session.settings().await.unwrap();
9080        assert!(!current.force_proxy);
9081
9082        session.shutdown().await.unwrap();
9083    }
9084
9085    // ---- M50: Session stats counters tests ----
9086
9087    #[tokio::test]
9088    async fn session_stats_counters_accessible() {
9089        let session = SessionHandle::start(test_settings()).await.unwrap();
9090        let counters = session.counters();
9091        // Exercise the uptime accessor (returns u64, so >= 0 is tautological;
9092        // the meaningful check is that the call doesn't panic and counters
9093        // are wired up).
9094        let _ = counters.uptime_secs();
9095        assert_eq!(counters.len(), crate::stats::NUM_METRICS);
9096        session.shutdown().await.unwrap();
9097    }
9098
9099    #[tokio::test]
9100    async fn post_session_stats_fires_alert() {
9101        use crate::alert::{AlertCategory, AlertKind};
9102
9103        let session = SessionHandle::start(test_settings()).await.unwrap();
9104        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9105
9106        session.post_session_stats().await.unwrap();
9107
9108        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
9109            .await
9110            .expect("timed out waiting for SessionStatsAlert")
9111            .expect("recv error");
9112        assert!(
9113            matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
9114            "expected SessionStatsAlert with {} values, got {:?}",
9115            crate::stats::NUM_METRICS,
9116            alert.kind,
9117        );
9118        session.shutdown().await.unwrap();
9119    }
9120
9121    #[tokio::test]
9122    async fn session_stats_include_torrent_count() {
9123        use crate::alert::{AlertCategory, AlertKind};
9124
9125        let session = SessionHandle::start(test_settings()).await.unwrap();
9126        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9127
9128        // Add a torrent
9129        let data = vec![0xAB; 16384];
9130        let meta = make_test_torrent(&data, 16384);
9131        let storage = make_storage(&data, 16384);
9132        session
9133            .add_torrent_with_meta(meta.into(), Some(storage))
9134            .await
9135            .unwrap();
9136
9137        session.post_session_stats().await.unwrap();
9138
9139        let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
9140            .await
9141            .expect("timed out waiting for SessionStatsAlert")
9142            .expect("recv error");
9143        match alert.kind {
9144            AlertKind::SessionStatsAlert { values } => {
9145                assert!(
9146                    values[crate::stats::SES_NUM_TORRENTS] > 0,
9147                    "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
9148                    values[crate::stats::SES_NUM_TORRENTS],
9149                );
9150            }
9151            other => panic!("expected SessionStatsAlert, got {other:?}"),
9152        }
9153        session.shutdown().await.unwrap();
9154    }
9155
9156    #[tokio::test]
9157    async fn stats_timer_disabled_when_zero() {
9158        use crate::alert::AlertCategory;
9159
9160        let mut config = test_settings();
9161        config.stats_report_interval = 0;
9162        let session = SessionHandle::start(config).await.unwrap();
9163        let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
9164
9165        // Wait 200ms — no periodic stats alert should arrive
9166        let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
9167        assert!(
9168            result.is_err(),
9169            "no SessionStatsAlert should fire when stats_report_interval is 0"
9170        );
9171        session.shutdown().await.unwrap();
9172    }
9173
9174    #[tokio::test]
9175    async fn sample_infohashes_timer_disabled_when_zero() {
9176        use crate::alert::AlertCategory;
9177
9178        let mut config = test_settings();
9179        config.dht_sample_infohashes_interval = 0;
9180        let session = SessionHandle::start(config).await.unwrap();
9181        let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
9182
9183        // Wait 200ms — no DhtSampleInfohashes alert should arrive
9184        let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
9185        assert!(
9186            result.is_err(),
9187            "no DhtSampleInfohashes alert should fire when interval is 0"
9188        );
9189        session.shutdown().await.unwrap();
9190    }
9191
9192    // ---- Test: open_file returns TorrentNotFound for unknown hash ----
9193
9194    #[tokio::test]
9195    async fn open_file_not_found() {
9196        let session = SessionHandle::start(test_settings()).await.unwrap();
9197        let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9198        let result = session.open_file(fake_hash, 0).await;
9199        assert!(result.is_err());
9200        let err = result.err().unwrap();
9201        assert!(err.to_string().contains("not found"));
9202        session.shutdown().await.unwrap();
9203    }
9204
9205    // ---- Test: open_file on a real torrent routes to TorrentHandle ----
9206
9207    #[tokio::test]
9208    async fn open_file_routes_to_torrent() {
9209        let session = SessionHandle::start(test_settings()).await.unwrap();
9210        let data = vec![0xAB; 32768];
9211        let meta = make_test_torrent(&data, 16384);
9212        let storage = make_storage(&data, 16384);
9213
9214        let info_hash = session
9215            .add_torrent_with_meta(meta.into(), Some(storage))
9216            .await
9217            .unwrap();
9218
9219        // open_file should succeed for file_index 0 (single-file torrent)
9220        let stream = session.open_file(info_hash, 0).await;
9221        assert!(stream.is_ok(), "open_file should succeed for file_index 0");
9222
9223        // open_file should fail for out-of-range file_index
9224        let result = session.open_file(info_hash, 999).await;
9225        assert!(
9226            result.is_err(),
9227            "open_file should fail for invalid file_index"
9228        );
9229
9230        session.shutdown().await.unwrap();
9231    }
9232
9233    // ---- Test: force_reannounce via session ----
9234
9235    #[tokio::test]
9236    async fn session_force_reannounce() {
9237        let session = SessionHandle::start(test_settings()).await.unwrap();
9238        let data = vec![0xAB; 16384];
9239        let meta = make_test_torrent(&data, 16384);
9240        let storage = make_storage(&data, 16384);
9241        let info_hash = session
9242            .add_torrent_with_meta(meta.into(), Some(storage))
9243            .await
9244            .unwrap();
9245
9246        // Should succeed for a known torrent.
9247        let result = session.force_reannounce(info_hash).await;
9248        assert!(
9249            result.is_ok(),
9250            "force_reannounce should succeed: {result:?}"
9251        );
9252
9253        // Should fail for unknown torrent.
9254        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9255        assert!(session.force_reannounce(fake).await.is_err());
9256
9257        session.shutdown().await.unwrap();
9258    }
9259
9260    // ---- Test: tracker_list via session ----
9261
9262    #[tokio::test]
9263    async fn session_tracker_list() {
9264        let session = SessionHandle::start(test_settings()).await.unwrap();
9265        let data = vec![0xAB; 16384];
9266        let meta = make_test_torrent(&data, 16384);
9267        let storage = make_storage(&data, 16384);
9268        let info_hash = session
9269            .add_torrent_with_meta(meta.into(), Some(storage))
9270            .await
9271            .unwrap();
9272
9273        // Should succeed (empty list since test torrent has no announce URL).
9274        let trackers = session.tracker_list(info_hash).await.unwrap();
9275        assert!(trackers.is_empty(), "test torrent has no trackers");
9276
9277        // Should fail for unknown torrent.
9278        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9279        assert!(session.tracker_list(fake).await.is_err());
9280
9281        session.shutdown().await.unwrap();
9282    }
9283
9284    // ---- Test: scrape via session ----
9285
9286    #[tokio::test]
9287    async fn session_scrape() {
9288        let session = SessionHandle::start(test_settings()).await.unwrap();
9289        let data = vec![0xAB; 16384];
9290        let meta = make_test_torrent(&data, 16384);
9291        let storage = make_storage(&data, 16384);
9292        let info_hash = session
9293            .add_torrent_with_meta(meta.into(), Some(storage))
9294            .await
9295            .unwrap();
9296
9297        // Should succeed (None since test torrent has no trackers to scrape).
9298        let scrape = session.scrape(info_hash).await.unwrap();
9299        assert!(scrape.is_none(), "test torrent has no trackers to scrape");
9300
9301        // Should fail for unknown torrent.
9302        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9303        assert!(session.scrape(fake).await.is_err());
9304
9305        session.shutdown().await.unwrap();
9306    }
9307
9308    // ---- Test: set_file_priority via session ----
9309
9310    #[tokio::test]
9311    async fn session_set_file_priority() {
9312        let session = SessionHandle::start(test_settings()).await.unwrap();
9313        let data = vec![0xAB; 16384];
9314        let meta = make_test_torrent(&data, 16384);
9315        let storage = make_storage(&data, 16384);
9316        let info_hash = session
9317            .add_torrent_with_meta(meta.into(), Some(storage))
9318            .await
9319            .unwrap();
9320
9321        // Should succeed for file index 0 (single-file torrent).
9322        let result = session
9323            .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
9324            .await;
9325        assert!(
9326            result.is_ok(),
9327            "set_file_priority should succeed: {result:?}"
9328        );
9329
9330        // Should fail for unknown torrent.
9331        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9332        assert!(
9333            session
9334                .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
9335                .await
9336                .is_err()
9337        );
9338
9339        session.shutdown().await.unwrap();
9340    }
9341
9342    // ---- Test: file_priorities via session ----
9343
9344    #[tokio::test]
9345    async fn session_file_priorities() {
9346        let session = SessionHandle::start(test_settings()).await.unwrap();
9347        let data = vec![0xAB; 16384];
9348        let meta = make_test_torrent(&data, 16384);
9349        let storage = make_storage(&data, 16384);
9350        let info_hash = session
9351            .add_torrent_with_meta(meta.into(), Some(storage))
9352            .await
9353            .unwrap();
9354
9355        // Should return priorities for the single file.
9356        let priorities = session.file_priorities(info_hash).await.unwrap();
9357        assert_eq!(
9358            priorities.len(),
9359            1,
9360            "single-file torrent should have 1 file priority"
9361        );
9362        assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
9363
9364        // Should fail for unknown torrent.
9365        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9366        assert!(session.file_priorities(fake).await.is_err());
9367
9368        session.shutdown().await.unwrap();
9369    }
9370
9371    // ---- Test: set_download_limit zero means unlimited ----
9372
9373    #[tokio::test]
9374    async fn set_download_limit_zero_means_unlimited() {
9375        let session = SessionHandle::start(test_settings()).await.unwrap();
9376        let data = vec![0xAB; 16384];
9377        let meta = make_test_torrent(&data, 16384);
9378        let storage = make_storage(&data, 16384);
9379        let info_hash = session
9380            .add_torrent_with_meta(meta.into(), Some(storage))
9381            .await
9382            .unwrap();
9383
9384        // Set limit to non-zero, then back to zero (unlimited).
9385        session.set_download_limit(info_hash, 50_000).await.unwrap();
9386        session.set_download_limit(info_hash, 0).await.unwrap();
9387        let limit = session.download_limit(info_hash).await.unwrap();
9388        assert_eq!(limit, 0, "0 means unlimited");
9389
9390        session.shutdown().await.unwrap();
9391    }
9392
9393    // ---- Test: set_upload_limit persists ----
9394
9395    #[tokio::test]
9396    async fn set_upload_limit_persists() {
9397        let session = SessionHandle::start(test_settings()).await.unwrap();
9398        let data = vec![0xAB; 16384];
9399        let meta = make_test_torrent(&data, 16384);
9400        let storage = make_storage(&data, 16384);
9401        let info_hash = session
9402            .add_torrent_with_meta(meta.into(), Some(storage))
9403            .await
9404            .unwrap();
9405
9406        session.set_upload_limit(info_hash, 100_000).await.unwrap();
9407        let limit = session.upload_limit(info_hash).await.unwrap();
9408        assert_eq!(limit, 100_000);
9409
9410        session.shutdown().await.unwrap();
9411    }
9412
9413    // ---- Test: download_limit default is zero ----
9414
9415    #[tokio::test]
9416    async fn download_limit_default_is_zero() {
9417        let session = SessionHandle::start(test_settings()).await.unwrap();
9418        let data = vec![0xAB; 16384];
9419        let meta = make_test_torrent(&data, 16384);
9420        let storage = make_storage(&data, 16384);
9421        let info_hash = session
9422            .add_torrent_with_meta(meta.into(), Some(storage))
9423            .await
9424            .unwrap();
9425
9426        // Default config has download_rate_limit = 0.
9427        let limit = session.download_limit(info_hash).await.unwrap();
9428        assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
9429
9430        session.shutdown().await.unwrap();
9431    }
9432
9433    // ---- Test: rate_limit_round_trip ----
9434
9435    #[tokio::test]
9436    async fn rate_limit_round_trip() {
9437        let session = SessionHandle::start(test_settings()).await.unwrap();
9438        let data = vec![0xAB; 16384];
9439        let meta = make_test_torrent(&data, 16384);
9440        let storage = make_storage(&data, 16384);
9441        let info_hash = session
9442            .add_torrent_with_meta(meta.into(), Some(storage))
9443            .await
9444            .unwrap();
9445
9446        // Set both limits.
9447        session
9448            .set_download_limit(info_hash, 1_000_000)
9449            .await
9450            .unwrap();
9451        session.set_upload_limit(info_hash, 500_000).await.unwrap();
9452
9453        // Read them back.
9454        let dl = session.download_limit(info_hash).await.unwrap();
9455        let ul = session.upload_limit(info_hash).await.unwrap();
9456        assert_eq!(dl, 1_000_000);
9457        assert_eq!(ul, 500_000);
9458
9459        // Update and verify again.
9460        session
9461            .set_download_limit(info_hash, 2_000_000)
9462            .await
9463            .unwrap();
9464        let dl = session.download_limit(info_hash).await.unwrap();
9465        assert_eq!(dl, 2_000_000);
9466
9467        // Unknown torrent should fail.
9468        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9469        assert!(session.download_limit(fake).await.is_err());
9470        assert!(session.upload_limit(fake).await.is_err());
9471        assert!(session.set_download_limit(fake, 100).await.is_err());
9472        assert!(session.set_upload_limit(fake, 100).await.is_err());
9473
9474        session.shutdown().await.unwrap();
9475    }
9476
9477    // ---- Test: sequential_download_toggle ----
9478
9479    #[tokio::test]
9480    async fn sequential_download_toggle() {
9481        let session = SessionHandle::start(test_settings()).await.unwrap();
9482        let data = vec![0xAB; 16384];
9483        let meta = make_test_torrent(&data, 16384);
9484        let storage = make_storage(&data, 16384);
9485        let info_hash = session
9486            .add_torrent_with_meta(meta.into(), Some(storage))
9487            .await
9488            .unwrap();
9489
9490        // Enable sequential download.
9491        session
9492            .set_sequential_download(info_hash, true)
9493            .await
9494            .unwrap();
9495        assert!(session.is_sequential_download(info_hash).await.unwrap());
9496
9497        // Disable it again.
9498        session
9499            .set_sequential_download(info_hash, false)
9500            .await
9501            .unwrap();
9502        assert!(!session.is_sequential_download(info_hash).await.unwrap());
9503
9504        session.shutdown().await.unwrap();
9505    }
9506
9507    // ---- Test: super_seeding_toggle ----
9508
9509    #[tokio::test]
9510    async fn super_seeding_toggle() {
9511        let session = SessionHandle::start(test_settings()).await.unwrap();
9512        let data = vec![0xAB; 16384];
9513        let meta = make_test_torrent(&data, 16384);
9514        let storage = make_storage(&data, 16384);
9515        let info_hash = session
9516            .add_torrent_with_meta(meta.into(), Some(storage))
9517            .await
9518            .unwrap();
9519
9520        // Enable super seeding.
9521        session.set_super_seeding(info_hash, true).await.unwrap();
9522        assert!(session.is_super_seeding(info_hash).await.unwrap());
9523
9524        // Disable it again.
9525        session.set_super_seeding(info_hash, false).await.unwrap();
9526        assert!(!session.is_super_seeding(info_hash).await.unwrap());
9527
9528        session.shutdown().await.unwrap();
9529    }
9530
9531    // ---- Test: sequential_download_default_false ----
9532
9533    #[tokio::test]
9534    async fn sequential_download_default_false() {
9535        let session = SessionHandle::start(test_settings()).await.unwrap();
9536        let data = vec![0xAB; 16384];
9537        let meta = make_test_torrent(&data, 16384);
9538        let storage = make_storage(&data, 16384);
9539        let info_hash = session
9540            .add_torrent_with_meta(meta.into(), Some(storage))
9541            .await
9542            .unwrap();
9543
9544        // Default config has sequential_download = false.
9545        assert!(!session.is_sequential_download(info_hash).await.unwrap());
9546
9547        session.shutdown().await.unwrap();
9548    }
9549
9550    // ---- Test: super_seeding_default_false ----
9551
9552    #[tokio::test]
9553    async fn super_seeding_default_false() {
9554        let session = SessionHandle::start(test_settings()).await.unwrap();
9555        let data = vec![0xAB; 16384];
9556        let meta = make_test_torrent(&data, 16384);
9557        let storage = make_storage(&data, 16384);
9558        let info_hash = session
9559            .add_torrent_with_meta(meta.into(), Some(storage))
9560            .await
9561            .unwrap();
9562
9563        // Default config has super_seeding = false.
9564        assert!(!session.is_super_seeding(info_hash).await.unwrap());
9565
9566        session.shutdown().await.unwrap();
9567    }
9568
9569    // ---- M159 Test: seed_mode_flips_user_flag ----
9570
9571    #[tokio::test]
9572    async fn seed_mode_flips_user_flag() {
9573        let session = SessionHandle::start(test_settings()).await.unwrap();
9574        let data = vec![0xAB; 16384];
9575        let meta = make_test_torrent(&data, 16384);
9576        let storage = make_storage(&data, 16384);
9577        let info_hash = session
9578            .add_torrent_with_meta(meta.into(), Some(storage))
9579            .await
9580            .unwrap();
9581
9582        // Initial state: user_seed_mode defaults to false.
9583        let stats_before = session.torrent_stats(info_hash).await.unwrap();
9584        assert!(
9585            !stats_before.user_seed_mode,
9586            "new torrent should not start in user seed mode"
9587        );
9588
9589        // Enable user seed mode.
9590        session.set_seed_mode(info_hash, true).await.unwrap();
9591        let stats_on = session.torrent_stats(info_hash).await.unwrap();
9592        assert!(
9593            stats_on.user_seed_mode,
9594            "stats should reflect user_seed_mode=true after enabling"
9595        );
9596
9597        // Disable it again.
9598        session.set_seed_mode(info_hash, false).await.unwrap();
9599        let stats_off = session.torrent_stats(info_hash).await.unwrap();
9600        assert!(
9601            !stats_off.user_seed_mode,
9602            "stats should reflect user_seed_mode=false after disabling"
9603        );
9604
9605        session.shutdown().await.unwrap();
9606    }
9607
9608    // ---- M159 Test: seed_mode_round_trip ----
9609
9610    #[tokio::test]
9611    async fn seed_mode_round_trip() {
9612        // Five flips in a row, exercising the actor's toggle idempotency and
9613        // piece-reservation cleanup logic. Must not panic and must leave the
9614        // flag consistent with the most recent call.
9615        let session = SessionHandle::start(test_settings()).await.unwrap();
9616        let data = vec![0xAB; 16384];
9617        let meta = make_test_torrent(&data, 16384);
9618        let storage = make_storage(&data, 16384);
9619        let info_hash = session
9620            .add_torrent_with_meta(meta.into(), Some(storage))
9621            .await
9622            .unwrap();
9623
9624        for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
9625            session.set_seed_mode(info_hash, *enabled).await.unwrap();
9626            let stats = session.torrent_stats(info_hash).await.unwrap();
9627            assert_eq!(
9628                stats.user_seed_mode, *enabled,
9629                "iteration {i}: stats.user_seed_mode should track the toggle"
9630            );
9631        }
9632
9633        session.shutdown().await.unwrap();
9634    }
9635
9636    // ---- M159 Test: seed_mode_missing_info_hash_errors ----
9637
9638    #[tokio::test]
9639    async fn seed_mode_missing_info_hash_errors() {
9640        let session = SessionHandle::start(test_settings()).await.unwrap();
9641        let fake =
9642            irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
9643        let err = session
9644            .set_seed_mode(fake, true)
9645            .await
9646            .expect_err("set_seed_mode on unknown info hash must return an error");
9647        match err {
9648            crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9649            other => panic!("expected TorrentNotFound, got {other:?}"),
9650        }
9651        session.shutdown().await.unwrap();
9652    }
9653
9654    // ---- M159 Test: seed_mode_idempotent ----
9655
9656    #[tokio::test]
9657    async fn seed_mode_idempotent() {
9658        // Setting the same value twice must not panic or corrupt state.
9659        let session = SessionHandle::start(test_settings()).await.unwrap();
9660        let data = vec![0xAB; 16384];
9661        let meta = make_test_torrent(&data, 16384);
9662        let storage = make_storage(&data, 16384);
9663        let info_hash = session
9664            .add_torrent_with_meta(meta.into(), Some(storage))
9665            .await
9666            .unwrap();
9667
9668        // Set true twice — second call is a no-op.
9669        session.set_seed_mode(info_hash, true).await.unwrap();
9670        session.set_seed_mode(info_hash, true).await.unwrap();
9671        assert!(
9672            session
9673                .torrent_stats(info_hash)
9674                .await
9675                .unwrap()
9676                .user_seed_mode
9677        );
9678
9679        // Set false twice — also a no-op the second time.
9680        session.set_seed_mode(info_hash, false).await.unwrap();
9681        session.set_seed_mode(info_hash, false).await.unwrap();
9682        assert!(
9683            !session
9684                .torrent_stats(info_hash)
9685                .await
9686                .unwrap()
9687                .user_seed_mode
9688        );
9689
9690        session.shutdown().await.unwrap();
9691    }
9692
9693    // ---- Test: add_tracker_increases_count ----
9694
9695    #[tokio::test]
9696    async fn add_tracker_increases_count() {
9697        let session = SessionHandle::start(test_settings()).await.unwrap();
9698        let data = vec![0xAB; 16384];
9699        let meta = make_test_torrent(&data, 16384);
9700        let storage = make_storage(&data, 16384);
9701        let info_hash = session
9702            .add_torrent_with_meta(meta.into(), Some(storage))
9703            .await
9704            .unwrap();
9705
9706        // Test torrent has no trackers initially.
9707        let before = session.tracker_list(info_hash).await.unwrap();
9708        assert!(before.is_empty());
9709
9710        // Add a tracker.
9711        session
9712            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9713            .await
9714            .unwrap();
9715
9716        let after = session.tracker_list(info_hash).await.unwrap();
9717        assert_eq!(after.len(), 1);
9718        assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9719
9720        session.shutdown().await.unwrap();
9721    }
9722
9723    // ---- Test: replace_trackers_replaces_all ----
9724
9725    #[tokio::test]
9726    async fn replace_trackers_replaces_all() {
9727        let session = SessionHandle::start(test_settings()).await.unwrap();
9728        let data = vec![0xAB; 16384];
9729        let meta = make_test_torrent(&data, 16384);
9730        let storage = make_storage(&data, 16384);
9731        let info_hash = session
9732            .add_torrent_with_meta(meta.into(), Some(storage))
9733            .await
9734            .unwrap();
9735
9736        // Add 2 trackers.
9737        session
9738            .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9739            .await
9740            .unwrap();
9741        session
9742            .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9743            .await
9744            .unwrap();
9745        assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9746
9747        // Replace with 1 different tracker.
9748        session
9749            .replace_trackers(
9750                info_hash,
9751                vec!["http://replacement.example.com/announce".into()],
9752            )
9753            .await
9754            .unwrap();
9755
9756        let after = session.tracker_list(info_hash).await.unwrap();
9757        assert_eq!(after.len(), 1);
9758        assert_eq!(after[0].url, "http://replacement.example.com/announce");
9759
9760        session.shutdown().await.unwrap();
9761    }
9762
9763    // ---- Test: add_tracker_deduplicates ----
9764
9765    #[tokio::test]
9766    async fn add_tracker_deduplicates() {
9767        let session = SessionHandle::start(test_settings()).await.unwrap();
9768        let data = vec![0xAB; 16384];
9769        let meta = make_test_torrent(&data, 16384);
9770        let storage = make_storage(&data, 16384);
9771        let info_hash = session
9772            .add_torrent_with_meta(meta.into(), Some(storage))
9773            .await
9774            .unwrap();
9775
9776        // Add the same tracker URL twice.
9777        session
9778            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9779            .await
9780            .unwrap();
9781        session
9782            .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9783            .await
9784            .unwrap();
9785
9786        // Should only have 1 tracker (deduplicated).
9787        let trackers = session.tracker_list(info_hash).await.unwrap();
9788        assert_eq!(trackers.len(), 1);
9789
9790        session.shutdown().await.unwrap();
9791    }
9792
9793    // ---- Test: info_hashes_matches_added_torrent ----
9794
9795    #[tokio::test]
9796    async fn info_hashes_matches_added_torrent() {
9797        let session = SessionHandle::start(test_settings()).await.unwrap();
9798        let data = vec![0xAB; 16384];
9799        let meta = make_test_torrent(&data, 16384);
9800        let expected_v1 = meta.info_hash;
9801        let storage = make_storage(&data, 16384);
9802
9803        let info_hash = session
9804            .add_torrent_with_meta(meta.into(), Some(storage))
9805            .await
9806            .unwrap();
9807        let hashes = session.info_hashes(info_hash).await.unwrap();
9808        assert_eq!(hashes.v1, Some(expected_v1));
9809        // v1-only torrent should not have v2 hash
9810        assert!(hashes.v2.is_none());
9811
9812        session.shutdown().await.unwrap();
9813    }
9814
9815    // ---- Test: torrent_file_returns_meta ----
9816
9817    #[tokio::test]
9818    async fn torrent_file_returns_meta() {
9819        let session = SessionHandle::start(test_settings()).await.unwrap();
9820        let data = vec![0xAB; 32768];
9821        let meta = make_test_torrent(&data, 16384);
9822        let storage = make_storage(&data, 16384);
9823
9824        let info_hash = session
9825            .add_torrent_with_meta(meta.into(), Some(storage))
9826            .await
9827            .unwrap();
9828        let torrent = session.torrent_file(info_hash).await.unwrap();
9829        assert!(torrent.is_some());
9830        let torrent = torrent.unwrap();
9831        assert_eq!(torrent.info_hash, info_hash);
9832        assert_eq!(torrent.info.name, "test");
9833        assert_eq!(torrent.info.total_length(), 32768);
9834
9835        session.shutdown().await.unwrap();
9836    }
9837
9838    // ---- Test: torrent_file_none_before_metadata ----
9839
9840    #[tokio::test]
9841    async fn torrent_file_none_before_metadata() {
9842        let session = SessionHandle::start(test_settings()).await.unwrap();
9843        let magnet = irontide_core::Magnet::parse(
9844            "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9845        )
9846        .unwrap();
9847
9848        let info_hash = session.add_magnet(magnet).await.unwrap();
9849        let torrent = session.torrent_file(info_hash).await.unwrap();
9850        // Before metadata is received, torrent_file should return None.
9851        assert!(torrent.is_none());
9852
9853        session.shutdown().await.unwrap();
9854    }
9855
9856    // ---- Test: force_dht_announce_no_error ----
9857
9858    #[tokio::test]
9859    async fn force_dht_announce_no_error() {
9860        let session = SessionHandle::start(test_settings()).await.unwrap();
9861        let data = vec![0xAB; 16384];
9862        let meta = make_test_torrent(&data, 16384);
9863        let storage = make_storage(&data, 16384);
9864        let info_hash = session
9865            .add_torrent_with_meta(meta.into(), Some(storage))
9866            .await
9867            .unwrap();
9868
9869        // Should succeed even without DHT enabled (no-op, no error).
9870        let result = session.force_dht_announce(info_hash).await;
9871        assert!(
9872            result.is_ok(),
9873            "force_dht_announce should succeed: {result:?}"
9874        );
9875
9876        // Should fail for unknown torrent.
9877        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9878        assert!(session.force_dht_announce(fake).await.is_err());
9879
9880        session.shutdown().await.unwrap();
9881    }
9882
9883    // ---- Test: force_lsd_announce_no_error ----
9884
9885    #[tokio::test]
9886    async fn force_lsd_announce_no_error() {
9887        let session = SessionHandle::start(test_settings()).await.unwrap();
9888        let data = vec![0xAB; 16384];
9889        let meta = make_test_torrent(&data, 16384);
9890        let storage = make_storage(&data, 16384);
9891        let info_hash = session
9892            .add_torrent_with_meta(meta.into(), Some(storage))
9893            .await
9894            .unwrap();
9895
9896        // Should succeed even without LSD enabled (no-op announce, no error).
9897        let result = session.force_lsd_announce(info_hash).await;
9898        assert!(
9899            result.is_ok(),
9900            "force_lsd_announce should succeed: {result:?}"
9901        );
9902
9903        // Should fail for unknown torrent.
9904        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9905        assert!(session.force_lsd_announce(fake).await.is_err());
9906
9907        session.shutdown().await.unwrap();
9908    }
9909
9910    // ---- Test: read_piece_after_download ----
9911
9912    #[tokio::test]
9913    async fn read_piece_after_download() {
9914        let data = vec![0xCD; 32768]; // 2 pieces of 16384
9915        let meta = make_test_torrent(&data, 16384);
9916        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9917        let storage = Arc::new(MemoryStorage::new(lengths));
9918        // Pre-fill storage with the data
9919        storage.write_chunk(0, 0, &data[..16384]).unwrap();
9920        storage.write_chunk(1, 0, &data[16384..]).unwrap();
9921
9922        let session = SessionHandle::start(test_settings()).await.unwrap();
9923        let info_hash = session
9924            .add_torrent_with_meta(meta.into(), Some(storage))
9925            .await
9926            .unwrap();
9927
9928        // Read piece 0
9929        let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9930        assert_eq!(piece_data.len(), 16384);
9931        assert!(piece_data.iter().all(|&b| b == 0xCD));
9932
9933        // Read piece 1
9934        let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9935        assert_eq!(piece_data.len(), 16384);
9936        assert!(piece_data.iter().all(|&b| b == 0xCD));
9937
9938        // Out-of-range piece should fail
9939        let result = session.read_piece(info_hash, 999).await;
9940        assert!(result.is_err(), "read_piece out of range should fail");
9941
9942        // Unknown torrent should fail
9943        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9944        assert!(session.read_piece(fake, 0).await.is_err());
9945
9946        session.shutdown().await.unwrap();
9947    }
9948
9949    // ---- Test: flush_cache_completes ----
9950
9951    #[tokio::test]
9952    async fn flush_cache_completes() {
9953        let session = SessionHandle::start(test_settings()).await.unwrap();
9954        let data = vec![0xAB; 16384];
9955        let meta = make_test_torrent(&data, 16384);
9956        let storage = make_storage(&data, 16384);
9957        let info_hash = session
9958            .add_torrent_with_meta(meta.into(), Some(storage))
9959            .await
9960            .unwrap();
9961
9962        // flush_cache should succeed.
9963        let result = session.flush_cache(info_hash).await;
9964        assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9965
9966        // Should fail for unknown torrent.
9967        let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9968        assert!(session.flush_cache(fake).await.is_err());
9969
9970        session.shutdown().await.unwrap();
9971    }
9972
9973    // ---- BEP 44 session API tests ----
9974
9975    fn test_settings_with_dht() -> Settings {
9976        let mut s = test_settings();
9977        s.enable_dht = true;
9978        s
9979    }
9980
9981    fn test_settings_with_lsd() -> Settings {
9982        let mut s = test_settings();
9983        s.enable_lsd = true;
9984        s
9985    }
9986
9987    #[tokio::test]
9988    async fn test_dht_disabled_returns_error() {
9989        let session = SessionHandle::start(test_settings()).await.unwrap();
9990
9991        // All 4 methods should fail with DhtDisabled when DHT is off
9992        let err = session
9993            .dht_put_immutable(b"test".to_vec())
9994            .await
9995            .unwrap_err();
9996        assert!(
9997            format!("{err:?}").contains("DhtDisabled"),
9998            "expected DhtDisabled, got {err:?}"
9999        );
10000
10001        let target = Id20::from([0u8; 20]);
10002        let err = session.dht_get_immutable(target).await.unwrap_err();
10003        assert!(
10004            format!("{err:?}").contains("DhtDisabled"),
10005            "expected DhtDisabled, got {err:?}"
10006        );
10007
10008        let err = session
10009            .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
10010            .await
10011            .unwrap_err();
10012        assert!(
10013            format!("{err:?}").contains("DhtDisabled"),
10014            "expected DhtDisabled, got {err:?}"
10015        );
10016
10017        let err = session
10018            .dht_get_mutable([42u8; 32], Vec::new())
10019            .await
10020            .unwrap_err();
10021        assert!(
10022            format!("{err:?}").contains("DhtDisabled"),
10023            "expected DhtDisabled, got {err:?}"
10024        );
10025
10026        session.shutdown().await.unwrap();
10027    }
10028
10029    #[tokio::test]
10030    async fn test_dht_put_get_immutable_round_trip() {
10031        let session = SessionHandle::start(test_settings_with_dht())
10032            .await
10033            .unwrap();
10034
10035        // Give DHT a moment to bootstrap (it won't find real nodes, but the handle should work)
10036        let value = b"hello BEP 44".to_vec();
10037        let target = session.dht_put_immutable(value.clone()).await.unwrap();
10038
10039        // The target should be the SHA-1 of the bencoded value
10040        // Try to get it back — since we're the only node, the local store should have it
10041        let got = session.dht_get_immutable(target).await.unwrap();
10042        assert_eq!(got, Some(value));
10043
10044        session.shutdown().await.unwrap();
10045    }
10046
10047    #[tokio::test]
10048    async fn test_dht_put_immutable_fires_alert() {
10049        use crate::alert::{AlertCategory, AlertKind};
10050
10051        let session = SessionHandle::start(test_settings_with_dht())
10052            .await
10053            .unwrap();
10054        let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
10055
10056        let value = b"alert test".to_vec();
10057        let target = session.dht_put_immutable(value).await.unwrap();
10058
10059        // Should receive DhtPutComplete alert
10060        let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
10061            .await
10062            .expect("timeout waiting for alert")
10063            .expect("alert channel closed");
10064
10065        match alert.kind {
10066            AlertKind::DhtPutComplete { target: t } => {
10067                assert_eq!(t, target);
10068            }
10069            other => panic!("expected DhtPutComplete, got {other:?}"),
10070        }
10071
10072        session.shutdown().await.unwrap();
10073    }
10074
10075    // ---- BEP 27: Private torrent LSD tests ----
10076
10077    /// Creates a private torrent (.torrent bytes with private=1 in the info dict).
10078    fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
10079        use serde::Serialize;
10080
10081        #[derive(Serialize)]
10082        struct Info<'a> {
10083            length: u64,
10084            name: &'a str,
10085            #[serde(rename = "piece length")]
10086            piece_length: u64,
10087            #[serde(with = "serde_bytes")]
10088            pieces: &'a [u8],
10089            private: i64,
10090        }
10091
10092        #[derive(Serialize)]
10093        struct Torrent<'a> {
10094            info: Info<'a>,
10095        }
10096
10097        let mut pieces = Vec::new();
10098        let mut offset = 0;
10099        while offset < data.len() {
10100            let end = (offset + piece_length as usize).min(data.len());
10101            let hash = irontide_core::sha1(&data[offset..end]);
10102            pieces.extend_from_slice(hash.as_bytes());
10103            offset = end;
10104        }
10105
10106        let t = Torrent {
10107            info: Info {
10108                length: data.len() as u64,
10109                name: "private-test",
10110                piece_length,
10111                pieces: &pieces,
10112                private: 1,
10113            },
10114        };
10115
10116        let bytes = irontide_bencode::to_bytes(&t).unwrap();
10117        torrent_from_bytes(&bytes).unwrap()
10118    }
10119
10120    #[test]
10121    fn is_private_true_via_parsed_meta() {
10122        // Verify that a torrent parsed from private .torrent bytes has private == Some(1)
10123        let data = vec![0xAB; 16384];
10124        let meta = make_private_torrent(&data, 16384);
10125        assert_eq!(
10126            meta.info.private,
10127            Some(1),
10128            "private field should be Some(1)"
10129        );
10130    }
10131
10132    #[test]
10133    fn is_private_false_for_public_torrent() {
10134        // Verify that a regular torrent has private == None
10135        let data = vec![0xAB; 16384];
10136        let meta = make_test_torrent(&data, 16384);
10137        assert_eq!(
10138            meta.info.private, None,
10139            "public torrent should have no private flag"
10140        );
10141    }
10142
10143    #[test]
10144    fn private_torrent_config_disables_lsd() {
10145        // Verify that TorrentConfig::default() has LSD enabled (so disable is meaningful)
10146        let config = TorrentConfig::default();
10147        assert!(
10148            config.enable_lsd,
10149            "default TorrentConfig should have LSD enabled"
10150        );
10151    }
10152
10153    #[tokio::test]
10154    async fn force_lsd_announce_private_torrent_returns_error() {
10155        let session = SessionHandle::start(test_settings()).await.unwrap();
10156        let data = vec![0xAB; 16384];
10157        let meta = make_private_torrent(&data, 16384);
10158        let storage = make_storage(&data, 16384);
10159        let info_hash = session
10160            .add_torrent_with_meta(meta.into(), Some(storage))
10161            .await
10162            .unwrap();
10163
10164        // BEP 27: force_lsd_announce on a private torrent must return an error
10165        let result = session.force_lsd_announce(info_hash).await;
10166        assert!(
10167            result.is_err(),
10168            "force_lsd_announce on private torrent should return error, got: {result:?}"
10169        );
10170        let err_str = format!("{:?}", result.unwrap_err());
10171        assert!(
10172            err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
10173            "expected InvalidSettings error, got: {err_str}"
10174        );
10175
10176        session.shutdown().await.unwrap();
10177    }
10178
10179    #[tokio::test]
10180    async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
10181        // v0.173.2 A10 companion to the DHT negative-form test added in T1.
10182        //
10183        // NEGATIVE form (codex finding #2): we only assert the BEP 27 gate doesn't
10184        // reject. We do NOT assert Ok — LSD may return Ok for a different reason
10185        // (e.g., self.lsd is None per session.rs:945's startup-failure swallow),
10186        // and asserting Ok would mask that case while still passing. We also
10187        // don't assert a specific Err — public torrents on an enabled-LSD session
10188        // typically return Ok, but a test-sandbox LSD init glitch could legitimately
10189        // error without the BEP 27 gate being involved.
10190        let session = SessionHandle::start(test_settings_with_lsd())
10191            .await
10192            .unwrap();
10193        let data = vec![0xAB; 16384];
10194        let meta = make_test_torrent(&data, 16384);
10195        let storage = make_storage(&data, 16384);
10196        let info_hash = session
10197            .add_torrent_with_meta(meta.into(), Some(storage))
10198            .await
10199            .unwrap();
10200
10201        let result = session.force_lsd_announce(info_hash).await;
10202        if let Err(e) = &result {
10203            assert!(
10204                !format!("{e:?}").contains("LSD disabled for private torrent"),
10205                "public torrent must NOT trigger BEP 27 error; got {e:?}"
10206            );
10207        }
10208
10209        session.shutdown().await.unwrap();
10210    }
10211
10212    #[tokio::test]
10213    async fn force_dht_announce_private_torrent_returns_error() {
10214        let session = SessionHandle::start(test_settings_with_dht())
10215            .await
10216            .unwrap();
10217        let data = vec![0xAB; 16384];
10218        let meta = make_private_torrent(&data, 16384);
10219        let storage = make_storage(&data, 16384);
10220        let info_hash = session
10221            .add_torrent_with_meta(meta.into(), Some(storage))
10222            .await
10223            .unwrap();
10224
10225        // BEP 27: force_dht_announce on a private torrent must return an error
10226        let result = session.force_dht_announce(info_hash).await;
10227        assert!(
10228            result.is_err(),
10229            "force_dht_announce on private torrent should return error, got: {result:?}"
10230        );
10231        let err_str = format!("{:?}", result.unwrap_err());
10232        assert!(
10233            err_str.contains("InvalidSettings")
10234                || err_str.contains("DHT disabled for private torrent"),
10235            "expected InvalidSettings / DHT-disabled error, got: {err_str}"
10236        );
10237
10238        session.shutdown().await.unwrap();
10239    }
10240
10241    #[tokio::test]
10242    async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
10243        let session = SessionHandle::start(test_settings_with_dht())
10244            .await
10245            .unwrap();
10246        let data = vec![0xAB; 16384];
10247        let meta = make_test_torrent(&data, 16384);
10248        let storage = make_storage(&data, 16384);
10249        let info_hash = session
10250            .add_torrent_with_meta(meta.into(), Some(storage))
10251            .await
10252            .unwrap();
10253
10254        let result = session.force_dht_announce(info_hash).await;
10255        // NEGATIVE form: we only assert the BEP 27 gate doesn't reject. We do NOT
10256        // assert Ok — DHT may not be initialised in the test sandbox, returning a
10257        // different error. Asserting Ok here would mask both the test sandbox
10258        // limitation AND a future regression where the BEP 27 gate accidentally
10259        // catches public torrents.
10260        if let Err(e) = &result {
10261            assert!(
10262                !format!("{e:?}").contains("DHT disabled for private torrent"),
10263                "public torrent must NOT trigger BEP 27 error; got {e:?}"
10264            );
10265        }
10266
10267        session.shutdown().await.unwrap();
10268    }
10269
10270    // ---- M161: save_resume_state tests ----
10271
10272    fn resume_test_settings(dir: &std::path::Path) -> Settings {
10273        Settings {
10274            resume_data_dir: Some(dir.to_path_buf()),
10275            save_resume_interval_secs: 0, // disable periodic timer for tests
10276            ..test_settings()
10277        }
10278    }
10279
10280    #[tokio::test]
10281    async fn save_resume_state_empty_session_returns_zero() {
10282        let tmp = tempfile::TempDir::new().unwrap();
10283        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10284            .await
10285            .unwrap();
10286
10287        let count = session.save_resume_state().await.unwrap();
10288        assert_eq!(count, 0, "empty session should save 0 resume files");
10289
10290        session.shutdown().await.unwrap();
10291    }
10292
10293    #[tokio::test]
10294    async fn save_resume_state_saves_dirty_torrents() {
10295        let tmp = tempfile::TempDir::new().unwrap();
10296        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10297            .await
10298            .unwrap();
10299
10300        // Add two torrents with different data so they get different hashes
10301        let data1 = vec![0xAA; 16384];
10302        let meta1 = make_test_torrent(&data1, 16384);
10303        let hash1 = meta1.info_hash;
10304        let storage1 = make_storage(&data1, 16384);
10305        session
10306            .add_torrent_with_meta(meta1.into(), Some(storage1))
10307            .await
10308            .unwrap();
10309
10310        let data2 = vec![0xBB; 16384];
10311        let meta2 = make_test_torrent(&data2, 16384);
10312        let hash2 = meta2.info_hash;
10313        let storage2 = make_storage(&data2, 16384);
10314        session
10315            .add_torrent_with_meta(meta2.into(), Some(storage2))
10316            .await
10317            .unwrap();
10318
10319        // Both torrents should be dirty (newly added → need_save_resume = true
10320        // after any state change). Give the actors a moment to settle.
10321        tokio::time::sleep(Duration::from_millis(50)).await;
10322
10323        let count = session.save_resume_state().await.unwrap();
10324        // Newly created torrents may or may not have the dirty flag set
10325        // depending on whether state changes have occurred. Verify that
10326        // at least the files are created for any that were dirty.
10327        assert!(count <= 2, "should save at most 2 resume files");
10328
10329        // Verify files exist on disk for saved torrents
10330        let torrents_dir = tmp.path().join("torrents");
10331        if count > 0 {
10332            assert!(torrents_dir.exists(), "torrents/ directory should exist");
10333        }
10334
10335        // Check specific file paths
10336        let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
10337        let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
10338        let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
10339        assert_eq!(
10340            files_exist, count,
10341            "number of files on disk should match returned count"
10342        );
10343
10344        session.shutdown().await.unwrap();
10345    }
10346
10347    #[tokio::test]
10348    async fn save_resume_state_round_trip() {
10349        let tmp = tempfile::TempDir::new().unwrap();
10350        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10351            .await
10352            .unwrap();
10353
10354        let data = vec![0xCD; 32768];
10355        let meta = make_test_torrent(&data, 16384);
10356        let info_hash = meta.info_hash;
10357        let storage = make_storage(&data, 16384);
10358        session
10359            .add_torrent_with_meta(meta.into(), Some(storage))
10360            .await
10361            .unwrap();
10362
10363        // Let the actor settle so dirty flag is set
10364        tokio::time::sleep(Duration::from_millis(50)).await;
10365
10366        let count = session.save_resume_state().await.unwrap();
10367
10368        // If the torrent was dirty and saved, verify round-trip
10369        if count > 0 {
10370            let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10371            assert!(path.exists(), "resume file should exist after save");
10372
10373            let bytes = std::fs::read(&path).unwrap();
10374            let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
10375            assert_eq!(
10376                rd.info_hash,
10377                info_hash.as_bytes().to_vec(),
10378                "deserialized info_hash should match"
10379            );
10380            assert_eq!(rd.name, "test", "deserialized name should match");
10381        }
10382
10383        session.shutdown().await.unwrap();
10384    }
10385
10386    #[tokio::test]
10387    async fn save_resume_state_clears_dirty_flag() {
10388        let tmp = tempfile::TempDir::new().unwrap();
10389        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10390            .await
10391            .unwrap();
10392
10393        let data = vec![0xEE; 16384];
10394        let meta = make_test_torrent(&data, 16384);
10395        let storage = make_storage(&data, 16384);
10396        session
10397            .add_torrent_with_meta(meta.into(), Some(storage))
10398            .await
10399            .unwrap();
10400
10401        tokio::time::sleep(Duration::from_millis(50)).await;
10402
10403        let first_count = session.save_resume_state().await.unwrap();
10404
10405        // Second save should return 0 because dirty flag was cleared
10406        let second_count = session.save_resume_state().await.unwrap();
10407        assert_eq!(
10408            second_count, 0,
10409            "second save should return 0 after dirty flag cleared (first saved {first_count})"
10410        );
10411
10412        session.shutdown().await.unwrap();
10413    }
10414
10415    #[tokio::test]
10416    async fn save_resume_state_second_save_skips_clean() {
10417        let tmp = tempfile::TempDir::new().unwrap();
10418        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10419            .await
10420            .unwrap();
10421
10422        let data1 = vec![0xAA; 16384];
10423        let meta1 = make_test_torrent(&data1, 16384);
10424        let storage1 = make_storage(&data1, 16384);
10425        session
10426            .add_torrent_with_meta(meta1.into(), Some(storage1))
10427            .await
10428            .unwrap();
10429
10430        let data2 = vec![0xBB; 16384];
10431        let meta2 = make_test_torrent(&data2, 16384);
10432        let storage2 = make_storage(&data2, 16384);
10433        session
10434            .add_torrent_with_meta(meta2.into(), Some(storage2))
10435            .await
10436            .unwrap();
10437
10438        tokio::time::sleep(Duration::from_millis(50)).await;
10439
10440        // First save
10441        let first = session.save_resume_state().await.unwrap();
10442
10443        // Second save — all flags should be cleared, nothing to write
10444        let second = session.save_resume_state().await.unwrap();
10445        assert_eq!(
10446            second, 0,
10447            "second save should skip all clean torrents (first saved {first})"
10448        );
10449
10450        session.shutdown().await.unwrap();
10451    }
10452
10453    // ==== M161 Phase 4: load_resume_state tests ====
10454
10455    // ---- Test: empty dir → zeros ----
10456
10457    #[tokio::test]
10458    async fn load_resume_empty_dir_returns_zeros() {
10459        let tmp = tempfile::TempDir::new().unwrap();
10460        let mut settings = test_settings();
10461        settings.resume_data_dir = Some(tmp.path().to_path_buf());
10462
10463        let session = SessionHandle::start(settings).await.unwrap();
10464        let result = session.load_resume_state().await.unwrap();
10465        assert_eq!(result.restored, 0);
10466        assert_eq!(result.skipped, 0);
10467        assert_eq!(result.failed, 0);
10468
10469        session.shutdown().await.unwrap();
10470    }
10471
10472    // ---- Test: corrupt file skipped ----
10473
10474    #[tokio::test]
10475    async fn load_resume_corrupt_file_counted_as_failed() {
10476        let tmp = tempfile::TempDir::new().unwrap();
10477        let torrents_dir = tmp.path().join("torrents");
10478        std::fs::create_dir_all(&torrents_dir).unwrap();
10479
10480        let mut settings = test_settings();
10481        settings.resume_data_dir = Some(tmp.path().to_path_buf());
10482
10483        // Start session first (auto-restore runs but dir is empty).
10484        let session = SessionHandle::start(settings).await.unwrap();
10485
10486        // Wait for auto-restore to complete before writing the file,
10487        // otherwise the actor may race with file creation.
10488        tokio::time::sleep(Duration::from_millis(50)).await;
10489
10490        // Write garbage to a .resume file *after* startup so auto-restore
10491        // does not consume it.
10492        std::fs::write(
10493            torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
10494            b"this is not valid bencode",
10495        )
10496        .unwrap();
10497
10498        let result = session.load_resume_state().await.unwrap();
10499        assert_eq!(result.restored, 0);
10500        assert_eq!(result.skipped, 0);
10501        assert_eq!(result.failed, 1);
10502
10503        session.shutdown().await.unwrap();
10504    }
10505
10506    // ---- Test: duplicate torrent skipped ----
10507
10508    #[tokio::test]
10509    async fn load_resume_duplicate_skipped() {
10510        let tmp = tempfile::TempDir::new().unwrap();
10511        let mut settings = test_settings();
10512        settings.resume_data_dir = Some(tmp.path().to_path_buf());
10513
10514        let session = SessionHandle::start(settings).await.unwrap();
10515
10516        // Add a torrent first.
10517        let data = vec![0xAB; 16384];
10518        let meta = make_test_torrent(&data, 16384);
10519        let info_hash = meta.info_hash;
10520        let storage = make_storage(&data, 16384);
10521        session
10522            .add_torrent_with_meta(meta.into(), Some(storage))
10523            .await
10524            .unwrap();
10525
10526        // Wait for the torrent to settle.
10527        tokio::time::sleep(Duration::from_millis(50)).await;
10528
10529        // Save resume state so we have a file on disk.
10530        let _ = session.save_resume_state().await;
10531
10532        // Load again — the torrent already exists so it should be skipped.
10533        let result = session.load_resume_state().await.unwrap();
10534        assert!(
10535            session.list_torrents().await.unwrap().contains(&info_hash),
10536            "original torrent should still exist"
10537        );
10538        assert_eq!(result.skipped, 1, "duplicate should be skipped");
10539        assert_eq!(result.failed, 0);
10540
10541        session.shutdown().await.unwrap();
10542    }
10543
10544    // ---- Test: reconstruct_torrent_meta with info present ----
10545
10546    #[test]
10547    fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
10548        use crate::resume_file::reconstruct_torrent_meta;
10549        use irontide_core::FastResumeData;
10550
10551        let data = vec![0xAB; 16384];
10552        let meta = make_test_torrent(&data, 16384);
10553        let info_hash = meta.info_hash;
10554
10555        // Create resume data with a stored info dict.
10556        let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
10557        let mut rd = FastResumeData::new(
10558            info_hash.as_bytes().to_vec(),
10559            "test-torrent".into(),
10560            "/downloads".into(),
10561        );
10562        rd.info = Some(info_bytes);
10563        rd.trackers = vec![
10564            vec!["http://tracker1.example.com/announce".into()],
10565            vec!["http://tracker2.example.com/announce".into()],
10566        ];
10567        rd.url_seeds = vec!["http://seed.example.com/".into()];
10568        rd.http_seeds = vec!["http://httpseed.example.com/".into()];
10569
10570        let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
10571
10572        assert_eq!(reconstructed.info_hash, info_hash);
10573        assert_eq!(
10574            reconstructed.announce.as_deref(),
10575            Some("http://tracker1.example.com/announce")
10576        );
10577        assert!(reconstructed.announce_list.is_some());
10578        assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
10579        assert_eq!(
10580            reconstructed.url_list,
10581            vec!["http://seed.example.com/".to_string()]
10582        );
10583        assert_eq!(
10584            reconstructed.httpseeds,
10585            vec!["http://httpseed.example.com/".to_string()]
10586        );
10587        assert!(reconstructed.info_bytes.is_some());
10588        assert!(reconstructed.comment.is_none());
10589        assert!(reconstructed.created_by.is_none());
10590        assert!(reconstructed.creation_date.is_none());
10591    }
10592
10593    // ---- Test: reconstruct_torrent_meta returns None for unresolved magnet ----
10594
10595    #[test]
10596    fn reconstruct_torrent_meta_returns_none_without_info() {
10597        use crate::resume_file::reconstruct_torrent_meta;
10598        use irontide_core::FastResumeData;
10599
10600        let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
10601        // info is None by default — simulates unresolved magnet.
10602        assert!(rd.info.is_none());
10603        assert!(reconstruct_torrent_meta(&rd).is_none());
10604    }
10605
10606    // ---- Test: reconstruct_magnet returns Some ----
10607
10608    #[test]
10609    fn reconstruct_magnet_returns_some_with_correct_fields() {
10610        use crate::resume_file::reconstruct_magnet;
10611        use irontide_core::FastResumeData;
10612
10613        let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
10614        rd.trackers = vec![
10615            vec!["http://tracker1.com/announce".into()],
10616            vec![
10617                "http://tracker2.com/announce".into(),
10618                "http://tracker3.com/announce".into(),
10619            ],
10620        ];
10621
10622        let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
10623
10624        assert!(magnet.info_hashes.v1.is_some());
10625        assert!(magnet.info_hashes.v2.is_none());
10626        assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
10627        // Trackers flattened: 3 total from 2 tiers.
10628        assert_eq!(magnet.trackers.len(), 3);
10629        assert!(magnet.peers.is_empty());
10630        assert!(magnet.selected_files.is_none());
10631    }
10632
10633    // ---- Test: reconstruct_magnet with info_hash2 preserved ----
10634
10635    #[test]
10636    fn reconstruct_magnet_preserves_info_hash2() {
10637        use crate::resume_file::reconstruct_magnet;
10638        use irontide_core::FastResumeData;
10639
10640        let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
10641        rd.info_hash2 = Some(vec![0xEE; 32]);
10642
10643        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10644        assert!(magnet.info_hashes.v1.is_some());
10645        assert!(magnet.info_hashes.v2.is_some());
10646
10647        let v2 = magnet.info_hashes.v2.unwrap();
10648        assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10649    }
10650
10651    // ---- Test: reconstruct_magnet with empty name ----
10652
10653    #[test]
10654    fn reconstruct_magnet_empty_name_is_none() {
10655        use crate::resume_file::reconstruct_magnet;
10656        use irontide_core::FastResumeData;
10657
10658        let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10659        let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10660        assert!(
10661            magnet.display_name.is_none(),
10662            "empty name should map to None"
10663        );
10664    }
10665
10666    // ==== M161 Phase 5: auto-save / auto-restore / orphan cleanup ====
10667
10668    // ---- Test: shutdown writes resume files ----
10669
10670    #[tokio::test]
10671    async fn shutdown_saves_resume_files() {
10672        let tmp = tempfile::TempDir::new().unwrap();
10673        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10674            .await
10675            .unwrap();
10676
10677        let data = vec![0xAB; 16384];
10678        let meta = make_test_torrent(&data, 16384);
10679        let info_hash = meta.info_hash;
10680        let storage = make_storage(&data, 16384);
10681        session
10682            .add_torrent_with_meta(meta.into(), Some(storage))
10683            .await
10684            .unwrap();
10685
10686        // Force a state change to set the dirty flag: pause then resume.
10687        session.pause_torrent(info_hash).await.unwrap();
10688        tokio::time::sleep(Duration::from_millis(50)).await;
10689        session.resume_torrent(info_hash).await.unwrap();
10690        tokio::time::sleep(Duration::from_millis(50)).await;
10691
10692        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10693
10694        // Shutdown triggers save_dirty_resume_files internally.
10695        // SessionHandle::shutdown() is fire-and-forget, so we need to
10696        // wait briefly for the actor to finish writing to disk.
10697        session.shutdown().await.unwrap();
10698        tokio::time::sleep(Duration::from_millis(200)).await;
10699
10700        assert!(path.exists(), "resume file should exist after shutdown");
10701    }
10702
10703    // ---- Test: auto-restore on startup ----
10704
10705    #[tokio::test]
10706    async fn auto_restore_on_startup() {
10707        let tmp = tempfile::TempDir::new().unwrap();
10708
10709        let info_hash;
10710        {
10711            // First session: add a torrent, save, and shut down.
10712            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10713                .await
10714                .unwrap();
10715
10716            let data = vec![0xAB; 16384];
10717            let meta = make_test_torrent(&data, 16384);
10718            info_hash = meta.info_hash;
10719            let storage = make_storage(&data, 16384);
10720            session
10721                .add_torrent_with_meta(meta.into(), Some(storage))
10722                .await
10723                .unwrap();
10724
10725            tokio::time::sleep(Duration::from_millis(50)).await;
10726            let _ = session.save_resume_state().await;
10727            session.shutdown().await.unwrap();
10728        }
10729
10730        // Verify the resume file exists before starting a new session.
10731        let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10732        assert!(path.exists(), "resume file should exist before restart");
10733
10734        {
10735            // Second session: should auto-restore the torrent on startup.
10736            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10737                .await
10738                .unwrap();
10739
10740            // Give the actor a moment to process the auto-restore.
10741            tokio::time::sleep(Duration::from_millis(100)).await;
10742
10743            let list = session.list_torrents().await.unwrap();
10744            assert!(
10745                list.contains(&info_hash),
10746                "torrent should be auto-restored on startup"
10747            );
10748
10749            session.shutdown().await.unwrap();
10750        }
10751    }
10752
10753    // ---- Test: shutdown with read-only resume dir completes without error ----
10754
10755    #[tokio::test]
10756    async fn shutdown_with_readonly_resume_dir_completes() {
10757        let tmp = tempfile::TempDir::new().unwrap();
10758        // Point resume_data_dir to a non-existent path under a read-only root.
10759        // On Linux, /proc is always read-only for directory creation.
10760        let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10761        let mut settings = test_settings();
10762        settings.resume_data_dir = Some(readonly_dir);
10763
10764        let session = SessionHandle::start(settings).await.unwrap();
10765
10766        let data = vec![0xAB; 16384];
10767        let meta = make_test_torrent(&data, 16384);
10768        let storage = make_storage(&data, 16384);
10769        session
10770            .add_torrent_with_meta(meta.into(), Some(storage))
10771            .await
10772            .unwrap();
10773
10774        tokio::time::sleep(Duration::from_millis(50)).await;
10775
10776        // Shutdown should complete without panic or error even though
10777        // the resume dir is not writable.
10778        session.shutdown().await.unwrap();
10779
10780        // If we got here, the test passed — errors were logged, not propagated.
10781        drop(tmp);
10782    }
10783
10784    // ---- Test: orphan resume file deleted on startup ----
10785
10786    #[tokio::test]
10787    async fn orphan_resume_file_deleted_on_startup() {
10788        let tmp = tempfile::TempDir::new().unwrap();
10789        let torrents_dir = tmp.path().join("torrents");
10790        std::fs::create_dir_all(&torrents_dir).unwrap();
10791
10792        // Write a fake .resume file that does not match any torrent.
10793        // Use valid bencode so it parses but with a hash that won't match
10794        // anything added to the session. The file must parse correctly for
10795        // the load to attempt adding it (which will fail or produce a torrent
10796        // with a mismatched hash that gets cleaned up as orphan).
10797        // Simplest: write garbage bencode — it will fail to deserialize,
10798        // not be added, and then orphan cleanup should remove it.
10799        let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10800        std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10801        assert!(orphan_path.exists(), "orphan file should exist before test");
10802
10803        let session = SessionHandle::start(resume_test_settings(tmp.path()))
10804            .await
10805            .unwrap();
10806
10807        // Give the actor time to auto-restore + orphan cleanup.
10808        tokio::time::sleep(Duration::from_millis(100)).await;
10809
10810        assert!(
10811            !orphan_path.exists(),
10812            "orphan resume file should be deleted on startup"
10813        );
10814
10815        session.shutdown().await.unwrap();
10816    }
10817
10818    // ==== M161 Phase 7: integration tests for resume file lifecycle ====
10819
10820    // ---- Test: multi-torrent save-load round-trip ----
10821    //
10822    // Creates 3 torrents in session 1, saves resume state, verifies 3 `.resume`
10823    // files on disk. Starts session 2 with the same resume dir and verifies all
10824    // 3 torrents are restored via `load_resume_state()`.
10825
10826    #[tokio::test]
10827    async fn multi_torrent_save_load_round_trip() {
10828        let tmp = tempfile::TempDir::new().unwrap();
10829
10830        // Distinct data per torrent to produce unique info hashes.
10831        let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10832        let mut hashes = Vec::with_capacity(3);
10833
10834        {
10835            // Session 1: add 3 torrents, save resume state.
10836            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10837                .await
10838                .unwrap();
10839
10840            for &byte in &datasets {
10841                let data = vec![byte; 16384];
10842                let meta = make_test_torrent(&data, 16384);
10843                let info_hash = meta.info_hash;
10844                let storage = make_storage(&data, 16384);
10845                session
10846                    .add_torrent_with_meta(meta.into(), Some(storage))
10847                    .await
10848                    .unwrap();
10849                hashes.push(info_hash);
10850            }
10851
10852            // Let actors settle so dirty flags are set.
10853            tokio::time::sleep(Duration::from_millis(100)).await;
10854
10855            let saved = session.save_resume_state().await.unwrap();
10856            assert_eq!(saved, 3, "all 3 torrents should be saved");
10857
10858            // Verify .resume files exist on disk.
10859            let files = crate::resume_file::scan_resume_dir(tmp.path());
10860            assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10861
10862            for hash in &hashes {
10863                let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10864                assert!(
10865                    path.exists(),
10866                    "resume file for {} should exist",
10867                    hex::encode(hash.as_bytes())
10868                );
10869            }
10870
10871            session.shutdown().await.unwrap();
10872        }
10873
10874        {
10875            // Session 2: fresh session with the same resume dir.
10876            // Disable auto-restore by starting first, then calling
10877            // load_resume_state manually.
10878            //
10879            // NOTE: the auto-restore runs during `start()` before we get the
10880            // handle back, so the torrents will already be loaded. Use
10881            // list_torrents to verify instead.
10882            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10883                .await
10884                .unwrap();
10885
10886            // Give the actor time to process auto-restore.
10887            tokio::time::sleep(Duration::from_millis(200)).await;
10888
10889            let list = session.list_torrents().await.unwrap();
10890            assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10891
10892            for hash in &hashes {
10893                assert!(
10894                    list.contains(hash),
10895                    "torrent {} should be present after restore",
10896                    hex::encode(hash.as_bytes())
10897                );
10898            }
10899
10900            session.shutdown().await.unwrap();
10901        }
10902    }
10903
10904    // ---- Test: corrupt 1 of 3 resume files → 2 restored + 1 failed ----
10905    //
10906    // Saves 3 torrents to resume files, corrupts one with garbage bytes,
10907    // then starts a fresh session and verifies that 2 are restored and 1 failed.
10908
10909    #[tokio::test]
10910    async fn corrupt_one_of_three_resume_files() {
10911        let tmp = tempfile::TempDir::new().unwrap();
10912
10913        let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10914        let mut hashes = Vec::with_capacity(3);
10915
10916        {
10917            // Session 1: add 3 torrents, save resume state.
10918            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10919                .await
10920                .unwrap();
10921
10922            for &byte in &datasets {
10923                let data = vec![byte; 16384];
10924                let meta = make_test_torrent(&data, 16384);
10925                let info_hash = meta.info_hash;
10926                let storage = make_storage(&data, 16384);
10927                session
10928                    .add_torrent_with_meta(meta.into(), Some(storage))
10929                    .await
10930                    .unwrap();
10931                hashes.push(info_hash);
10932            }
10933
10934            tokio::time::sleep(Duration::from_millis(100)).await;
10935
10936            let saved = session.save_resume_state().await.unwrap();
10937            assert_eq!(saved, 3, "all 3 torrents should be saved");
10938
10939            session.shutdown().await.unwrap();
10940        }
10941
10942        // Corrupt the second resume file with garbage bytes.
10943        let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10944        assert!(
10945            corrupt_path.exists(),
10946            "file to corrupt must exist before overwrite"
10947        );
10948        std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10949
10950        {
10951            // Session 2: auto-restore should recover 2, fail 1, and the
10952            // orphan cleanup should delete the corrupt file.
10953            let session = SessionHandle::start(resume_test_settings(tmp.path()))
10954                .await
10955                .unwrap();
10956
10957            // Give actor time for auto-restore + orphan cleanup.
10958            tokio::time::sleep(Duration::from_millis(200)).await;
10959
10960            let list = session.list_torrents().await.unwrap();
10961            assert_eq!(
10962                list.len(),
10963                2,
10964                "2 torrents should be restored (1 corrupt skipped)"
10965            );
10966
10967            // The good hashes should be present.
10968            assert!(
10969                list.contains(&hashes[0]),
10970                "first torrent should be restored"
10971            );
10972            assert!(
10973                list.contains(&hashes[2]),
10974                "third torrent should be restored"
10975            );
10976
10977            // The corrupt hash should NOT be present.
10978            assert!(
10979                !list.contains(&hashes[1]),
10980                "corrupted torrent should not be restored"
10981            );
10982
10983            // Also verify the corrupt file was cleaned up as orphan.
10984            assert!(
10985                !corrupt_path.exists(),
10986                "corrupt resume file should be deleted by orphan cleanup"
10987            );
10988
10989            session.shutdown().await.unwrap();
10990        }
10991    }
10992
10993    // ---- Test: remove torrent → `.resume` file deleted from disk ----
10994    //
10995    // Adds a torrent, saves resume state (creates the `.resume` file), then
10996    // removes the torrent via `session.remove_torrent()`. The removal handler
10997    // eagerly deletes the `.resume` file so it is not orphaned.
10998
10999    #[tokio::test]
11000    async fn remove_torrent_deletes_resume_file() {
11001        let tmp = tempfile::TempDir::new().unwrap();
11002
11003        let data = vec![0x42; 16384];
11004        let meta = make_test_torrent(&data, 16384);
11005        let info_hash = meta.info_hash;
11006        let storage = make_storage(&data, 16384);
11007
11008        let session = SessionHandle::start(resume_test_settings(tmp.path()))
11009            .await
11010            .unwrap();
11011
11012        session
11013            .add_torrent_with_meta(meta.into(), Some(storage))
11014            .await
11015            .unwrap();
11016
11017        // Let the actor settle so the dirty flag is set.
11018        tokio::time::sleep(Duration::from_millis(100)).await;
11019
11020        let saved = session.save_resume_state().await.unwrap();
11021        assert!(saved > 0, "torrent should be saved to a resume file");
11022
11023        let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
11024        assert!(resume_path.exists(), "resume file should exist after save");
11025
11026        // Remove the torrent — this should also delete the .resume file.
11027        session.remove_torrent(info_hash).await.unwrap();
11028        tokio::time::sleep(Duration::from_millis(50)).await;
11029
11030        let list = session.list_torrents().await.unwrap();
11031        assert!(
11032            !list.contains(&info_hash),
11033            "torrent should be gone from session after removal"
11034        );
11035
11036        assert!(
11037            !resume_path.exists(),
11038            "resume file should be deleted when torrent is removed"
11039        );
11040
11041        // Verify no .resume files remain in the torrents directory.
11042        let remaining = crate::resume_file::scan_resume_dir(tmp.path());
11043        assert!(
11044            remaining.is_empty(),
11045            "no resume files should remain after removing the only torrent"
11046        );
11047
11048        session.shutdown().await.unwrap();
11049    }
11050
11051    // ── M170: session-level storage-path tests ─────────────────────────
11052
11053    /// Test settings that use an isolated `resume_data_dir` so that
11054    /// auto-restore from a prior test run doesn't pollute `torrents`.
11055    /// Matches the precedent set by `test_settings_with_dht`.
11056    fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
11057        Settings {
11058            resume_data_dir: Some(resume_dir.to_path_buf()),
11059            ..test_settings()
11060        }
11061    }
11062
11063    #[tokio::test]
11064    async fn remove_torrent_with_files_deletes_disk_files() {
11065        // Build a real on-disk torrent via FilesystemStorage, then call
11066        // remove_torrent_with_files. The files MUST be gone from disk
11067        // after the spawn_blocking walk completes.
11068        let download_dir = tempfile::tempdir().unwrap();
11069        let resume_dir = tempfile::tempdir().unwrap();
11070        let mut settings = test_settings_isolated_resume(resume_dir.path());
11071        settings.download_dir = download_dir.path().to_path_buf();
11072        let session = SessionHandle::start(settings).await.unwrap();
11073
11074        let data = vec![0xAB_u8; 16384];
11075        let meta = make_test_torrent(&data, 16384);
11076        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11077        let storage: Arc<dyn TorrentStorage> = Arc::new(
11078            irontide_storage::FilesystemStorage::new(
11079                download_dir.path(),
11080                vec![PathBuf::from("test")],
11081                vec![data.len() as u64],
11082                lengths,
11083                None,
11084                irontide_storage::PreallocateMode::None,
11085                false,
11086            )
11087            .unwrap(),
11088        );
11089
11090        // Write actual piece data so the file is non-empty and cannot
11091        // be mistaken for a sparse leftover.
11092        storage.write_chunk(0, 0, &data).unwrap();
11093
11094        let info_hash = session
11095            .add_torrent_with_meta(meta.into(), Some(storage))
11096            .await
11097            .unwrap();
11098
11099        let file_on_disk = download_dir.path().join("test");
11100        assert!(file_on_disk.exists(), "file should exist before delete");
11101
11102        session.remove_torrent_with_files(info_hash).await.unwrap();
11103
11104        // The spawn_blocking task is fire-and-forget; poll briefly.
11105        for _ in 0..20 {
11106            if !file_on_disk.exists() {
11107                break;
11108            }
11109            tokio::time::sleep(Duration::from_millis(50)).await;
11110        }
11111        assert!(
11112            !file_on_disk.exists(),
11113            "file should have been removed from disk"
11114        );
11115        assert!(
11116            download_dir.path().exists(),
11117            "download_dir root must never be removed"
11118        );
11119
11120        session.shutdown().await.unwrap();
11121    }
11122
11123    #[tokio::test]
11124    async fn remove_torrent_with_files_tolerates_already_deleted_files() {
11125        // Partial-failure semantics: the user removed files out-of-band
11126        // before the session got the deleteFiles command. The call must
11127        // still succeed (always returns Ok).
11128        let download_dir = tempfile::tempdir().unwrap();
11129        let resume_dir = tempfile::tempdir().unwrap();
11130        let mut settings = test_settings_isolated_resume(resume_dir.path());
11131        settings.download_dir = download_dir.path().to_path_buf();
11132        let session = SessionHandle::start(settings).await.unwrap();
11133
11134        let data = vec![0xCD_u8; 16384];
11135        let meta = make_test_torrent(&data, 16384);
11136        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11137        let storage: Arc<dyn TorrentStorage> = Arc::new(
11138            irontide_storage::FilesystemStorage::new(
11139                download_dir.path(),
11140                vec![PathBuf::from("test")],
11141                vec![data.len() as u64],
11142                lengths,
11143                None,
11144                irontide_storage::PreallocateMode::None,
11145                false,
11146            )
11147            .unwrap(),
11148        );
11149        let info_hash = session
11150            .add_torrent_with_meta(meta.into(), Some(storage))
11151            .await
11152            .unwrap();
11153
11154        // Manually delete the file before calling remove_torrent_with_files.
11155        std::fs::remove_file(download_dir.path().join("test")).unwrap();
11156
11157        // Must still succeed.
11158        let result = session.remove_torrent_with_files(info_hash).await;
11159        assert!(
11160            result.is_ok(),
11161            "remove_torrent_with_files must return Ok on missing files"
11162        );
11163
11164        session.shutdown().await.unwrap();
11165    }
11166
11167    #[tokio::test]
11168    async fn remove_torrent_with_files_grace_guards_fast_re_add() {
11169        // Fast re-add during an in-flight delete must 409 until the
11170        // deletion grace window closes. Because the delete is fire-and-
11171        // forget, we simulate by calling remove_torrent_with_files and
11172        // then immediately re-adding the same info hash via
11173        // add_torrent(AddTorrentParams::bytes(...)).  The grace set is
11174        // populated synchronously inside the actor so the re-add sees it.
11175        use serde::Serialize;
11176
11177        #[derive(Serialize)]
11178        struct Info<'a> {
11179            length: u64,
11180            name: &'a str,
11181            #[serde(rename = "piece length")]
11182            piece_length: u64,
11183            #[serde(with = "serde_bytes")]
11184            pieces: &'a [u8],
11185        }
11186        #[derive(Serialize)]
11187        struct Torrent<'a> {
11188            info: Info<'a>,
11189        }
11190
11191        let download_dir = tempfile::tempdir().unwrap();
11192        let resume_dir = tempfile::tempdir().unwrap();
11193        let mut settings = test_settings_isolated_resume(resume_dir.path());
11194        settings.download_dir = download_dir.path().to_path_buf();
11195        let session = SessionHandle::start(settings).await.unwrap();
11196
11197        // Build + serialize a single-file torrent so we can re-add via
11198        // bytes after deleting.
11199        let data = vec![0xEE_u8; 16384];
11200        let meta = make_test_torrent(&data, 16384);
11201        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
11202        let storage: Arc<dyn TorrentStorage> = Arc::new(
11203            irontide_storage::FilesystemStorage::new(
11204                download_dir.path(),
11205                vec![PathBuf::from("test")],
11206                vec![data.len() as u64],
11207                lengths,
11208                None,
11209                irontide_storage::PreallocateMode::None,
11210                false,
11211            )
11212            .unwrap(),
11213        );
11214        // Re-serialise the TorrentMetaV1 so we can feed the bytes back
11215        // through AddTorrentParams::bytes.
11216        let mut pieces = Vec::new();
11217        let hash = irontide_core::sha1(&data);
11218        pieces.extend_from_slice(hash.as_bytes());
11219        let bytes = irontide_bencode::to_bytes(&Torrent {
11220            info: Info {
11221                length: data.len() as u64,
11222                name: "test",
11223                piece_length: 16384,
11224                pieces: &pieces,
11225            },
11226        })
11227        .unwrap();
11228
11229        let info_hash = session
11230            .add_torrent_with_meta(meta.into(), Some(storage))
11231            .await
11232            .unwrap();
11233
11234        // Kick off the delete — the deletion_grace set is populated
11235        // inside the actor before we return.
11236        session.remove_torrent_with_files(info_hash).await.unwrap();
11237
11238        // Immediately try to re-add. The grace window may still be
11239        // open; if it is, we expect 409/CategoryBeingRemoved. If the
11240        // spawn_blocking happened to finish first, we expect success.
11241        // Either way the system must NOT panic or leak a half-deleted
11242        // torrent.
11243        let params = AddTorrentParams::bytes(bytes);
11244        let result = session.add_torrent(params).await;
11245        match result {
11246            Ok(_) => {
11247                // grace window closed before the re-add — fine.
11248            }
11249            Err(crate::Error::TorrentBeingRemoved(h)) => {
11250                assert_eq!(h, info_hash, "grace error must name the same hash");
11251            }
11252            Err(e) => panic!("unexpected error on re-add: {e}"),
11253        }
11254
11255        session.shutdown().await.unwrap();
11256    }
11257
11258    // ---- v0.173.2 T2: synchronous debug_inject_metadata ----
11259
11260    /// Synthesise a v1 info dict and its SHA-1 info hash. Returns
11261    /// `(info_bytes, info_hash)` where `info_bytes` is the bencoded info
11262    /// dict alone (not the outer .torrent wrapper) and `info_hash` is the
11263    /// SHA-1 of those bytes.
11264    ///
11265    /// The injected info hash MUST match the magnet URI's info hash, so
11266    /// this helper owns that invariant: hash exactly the bytes that will
11267    /// later be injected.
11268    #[cfg(feature = "test-util")]
11269    fn make_debug_inject_info() -> (Vec<u8>, Id20) {
11270        use serde::Serialize;
11271
11272        #[derive(Serialize)]
11273        struct Info<'a> {
11274            length: u64,
11275            name: &'a str,
11276            #[serde(rename = "piece length")]
11277            piece_length: u64,
11278            #[serde(with = "serde_bytes")]
11279            pieces: &'a [u8],
11280        }
11281
11282        let data = vec![0xAB_u8; 1024];
11283        let piece_hash = irontide_core::sha1(&data);
11284        let mut pieces = Vec::new();
11285        pieces.extend_from_slice(piece_hash.as_bytes());
11286
11287        let info = Info {
11288            length: data.len() as u64,
11289            name: "sync-inject-test",
11290            piece_length: 1024,
11291            pieces: &pieces,
11292        };
11293
11294        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
11295        let info_hash = irontide_core::sha1(&info_bytes);
11296        (info_bytes, info_hash)
11297    }
11298
11299    #[cfg(feature = "test-util")]
11300    #[tokio::test]
11301    async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
11302        use crate::session::AddTorrentParams;
11303
11304        let (info_bytes, info_hash) = make_debug_inject_info();
11305
11306        // Isolate resume dir — magnet adds persist .resume files; without
11307        // isolation, parallel tests and re-runs pollute one another. See
11308        // feedback_irontide_resume_test_isolation memory entry.
11309        let resume_dir = tempfile::tempdir().unwrap();
11310        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11311            .await
11312            .unwrap();
11313
11314        let magnet_uri = format!(
11315            "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
11316            info_hash.to_hex()
11317        );
11318        let added = session
11319            .add_torrent(AddTorrentParams::magnet(magnet_uri))
11320            .await
11321            .unwrap();
11322        assert_eq!(
11323            added, info_hash,
11324            "magnet info hash must equal synth info hash"
11325        );
11326
11327        // The synchronous contract: when `debug_inject_metadata` returns
11328        // Ok, the metadata must already be visible via `torrent_file` — no
11329        // polling, no sleep. This is what distinguishes it from the M147
11330        // fire-and-forget path.
11331        session
11332            .debug_inject_metadata(info_hash, info_bytes)
11333            .await
11334            .expect("debug_inject_metadata must succeed");
11335
11336        let meta = session
11337            .torrent_file(info_hash)
11338            .await
11339            .expect("torrent_file call")
11340            .expect("metadata must be present immediately after sync inject");
11341        assert_eq!(meta.info_hash, info_hash);
11342
11343        session.shutdown().await.unwrap();
11344    }
11345
11346    #[cfg(feature = "test-util")]
11347    #[tokio::test]
11348    async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
11349        let resume_dir = tempfile::tempdir().unwrap();
11350        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11351            .await
11352            .unwrap();
11353
11354        let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
11355        let result = session.debug_inject_metadata(bogus, vec![]).await;
11356        assert!(
11357            matches!(result, Err(crate::Error::TorrentNotFound(_))),
11358            "expected TorrentNotFound for unknown hash; got {result:?}"
11359        );
11360
11361        session.shutdown().await.unwrap();
11362    }
11363
11364    // ---- v0.173.2 T7: A11 ssl_cert metadata propagation regression ----
11365
11366    /// Synthesise a v1 info dict with optional `private` and `ssl-cert`
11367    /// fields. Unlike [`make_debug_inject_info`], this helper exists
11368    /// specifically to exercise the BEP 35 `ssl-cert` propagation path
11369    /// from the bencoded info dict into `TorrentMetaV1::info.ssl_cert`
11370    /// after synchronous metadata injection (T2's `debug_inject_metadata`).
11371    ///
11372    /// The bencode key name is `ssl-cert` (with hyphen) per BEP 35; serde
11373    /// renames it on the synthesised `Info` struct below so the emitted
11374    /// bytes match `InfoDict`'s deserialiser exactly.
11375    ///
11376    /// The caller hashes the returned bytes directly to compute the info
11377    /// hash — the returned bytes are the info dict alone (no wrapper).
11378    #[cfg(feature = "test-util")]
11379    fn build_synth_info_bytes_with_options(
11380        name: &str,
11381        length_bytes: u64,
11382        piece_length: u64,
11383        private: Option<i64>,
11384        ssl_cert: Option<Vec<u8>>,
11385    ) -> Vec<u8> {
11386        use serde::Serialize;
11387
11388        #[derive(Serialize)]
11389        struct Info {
11390            length: u64,
11391            name: String,
11392            #[serde(rename = "piece length")]
11393            piece_length: u64,
11394            pieces: serde_bytes::ByteBuf,
11395            #[serde(skip_serializing_if = "Option::is_none")]
11396            private: Option<i64>,
11397            #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
11398            ssl_cert: Option<serde_bytes::ByteBuf>,
11399        }
11400
11401        // pieces = SHA-1 of an all-zero piece, repeated. The injected data
11402        // is never verified against disk during these tests — we only care
11403        // that the bencoded info dict round-trips through the session's
11404        // metadata-resolution path with ssl_cert intact.
11405        let num_pieces = length_bytes.div_ceil(piece_length);
11406        let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
11407        let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
11408        for _ in 0..num_pieces {
11409            pieces.extend_from_slice(zero_piece_hash.as_bytes());
11410        }
11411
11412        let info = Info {
11413            length: length_bytes,
11414            name: name.to_owned(),
11415            piece_length,
11416            pieces: serde_bytes::ByteBuf::from(pieces),
11417            private,
11418            ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
11419        };
11420        irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
11421    }
11422
11423    #[cfg(feature = "test-util")]
11424    #[tokio::test]
11425    async fn ssl_cert_propagates_to_meta_after_inject() {
11426        use crate::session::AddTorrentParams;
11427
11428        let resume_dir = tempfile::tempdir().unwrap();
11429        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11430            .await
11431            .unwrap();
11432
11433        let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
11434        let info_bytes = build_synth_info_bytes_with_options(
11435            "ssl-fixture",
11436            16_384,
11437            16_384,
11438            None,
11439            Some(cert_pem.clone()),
11440        );
11441        let info_hash = irontide_core::sha1(&info_bytes);
11442
11443        let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
11444        let added = session
11445            .add_torrent(AddTorrentParams::magnet(magnet))
11446            .await
11447            .unwrap();
11448        assert_eq!(
11449            added, info_hash,
11450            "magnet info hash must equal synth info hash"
11451        );
11452
11453        session
11454            .debug_inject_metadata(info_hash, info_bytes)
11455            .await
11456            .expect("debug_inject_metadata must succeed");
11457
11458        let meta = session
11459            .torrent_file(info_hash)
11460            .await
11461            .expect("torrent_file Ok")
11462            .expect("metadata must be present immediately after sync inject");
11463        assert_eq!(
11464            meta.info.ssl_cert.as_ref(),
11465            Some(&cert_pem),
11466            "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
11467        );
11468
11469        session.shutdown().await.unwrap();
11470    }
11471
11472    #[cfg(feature = "test-util")]
11473    #[tokio::test]
11474    async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
11475        use crate::session::AddTorrentParams;
11476
11477        let resume_dir = tempfile::tempdir().unwrap();
11478        let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
11479            .await
11480            .unwrap();
11481
11482        let info_bytes =
11483            build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
11484        let info_hash = irontide_core::sha1(&info_bytes);
11485
11486        let magnet = format!(
11487            "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
11488            info_hash.to_hex()
11489        );
11490        let added = session
11491            .add_torrent(AddTorrentParams::magnet(magnet))
11492            .await
11493            .unwrap();
11494        assert_eq!(
11495            added, info_hash,
11496            "magnet info hash must equal synth info hash"
11497        );
11498
11499        session
11500            .debug_inject_metadata(info_hash, info_bytes)
11501            .await
11502            .expect("debug_inject_metadata must succeed");
11503
11504        let meta = session
11505            .torrent_file(info_hash)
11506            .await
11507            .expect("torrent_file Ok")
11508            .expect("metadata must be present immediately after sync inject");
11509        assert!(
11510            meta.info.ssl_cert.is_none(),
11511            "absent ssl-cert in info dict must remain None in meta; got {:?}",
11512            meta.info.ssl_cert
11513        );
11514
11515        session.shutdown().await.unwrap();
11516    }
11517
11518    // ---- P2C4: Startup init throttle tests ----
11519
11520    #[tokio::test]
11521    async fn init_throttle_queues_restored_torrents() {
11522        let tmp = tempfile::TempDir::new().unwrap();
11523        let resume_dir = tmp.path().to_path_buf();
11524
11525        // Phase 1: create a session, add 5 torrents, save resume data.
11526        {
11527            let mut settings = resume_test_settings(&resume_dir);
11528            settings.queueing_enabled = false;
11529            let session = SessionHandle::start(settings).await.unwrap();
11530            for i in 0u8..5 {
11531                let data = vec![i.wrapping_add(0xA0); 16384];
11532                let meta = make_test_torrent(&data, 16384);
11533                let storage = make_storage(&data, 16384);
11534                session
11535                    .add_torrent_with_meta(meta.into(), Some(storage))
11536                    .await
11537                    .unwrap();
11538            }
11539            tokio::time::sleep(Duration::from_millis(100)).await;
11540            let saved = session.save_resume_state().await.unwrap();
11541            assert!(saved >= 3, "should save most resume files, got {saved}");
11542            session.shutdown().await.unwrap();
11543        }
11544
11545        // Phase 2: restart with queueing_enabled and active_checking=2.
11546        {
11547            let mut settings = resume_test_settings(&resume_dir);
11548            settings.queueing_enabled = true;
11549            settings.active_checking = 2;
11550            settings.active_downloads = 2;
11551            settings.active_seeds = 2;
11552            settings.active_limit = 4;
11553            let session = SessionHandle::start(settings).await.unwrap();
11554            // M245 P1: `list_torrent_summaries` is now snapshot-backed. The
11555            // per-torrent `state` field is a SAMPLED value refreshed on the
11556            // periodic stats tick (`stats_report_interval`, 1000 ms by default),
11557            // NOT re-published on every state transition (ratified D2:
11558            // membership is eager + read-after-write, sampled fields are
11559            // eventually-consistent to one tick). The init-throttle queues a
11560            // torrent in the actor promptly, but the snapshot reflects it on the
11561            // next tick — so poll until the tick converges instead of reading
11562            // once at a fixed delay. The bounded timeout still fails the test if
11563            // the throttle never queues anything (a genuine regression).
11564            let mut queued = 0;
11565            let mut active = 0;
11566            for _ in 0..60 {
11567                let list = session.list_torrent_summaries().await.unwrap();
11568                queued = list
11569                    .iter()
11570                    .filter(|t| t.state == TorrentState::Queued)
11571                    .count();
11572                active = list
11573                    .iter()
11574                    .filter(|t| t.state != TorrentState::Queued)
11575                    .count();
11576                if queued > 0 {
11577                    break;
11578                }
11579                tokio::time::sleep(Duration::from_millis(50)).await;
11580            }
11581
11582            assert!(
11583                queued > 0,
11584                "at least one torrent should be Queued after a stats tick, but all {active} are active"
11585            );
11586            assert!(
11587                active <= 4,
11588                "active torrents ({active}) should not exceed active_limit (4)"
11589            );
11590            session.shutdown().await.unwrap();
11591        }
11592    }
11593
11594    #[tokio::test]
11595    async fn init_throttle_disabled_restores_all_immediately() {
11596        let tmp = tempfile::TempDir::new().unwrap();
11597        let resume_dir = tmp.path().to_path_buf();
11598
11599        // Phase 1: add torrents, save resume.
11600        {
11601            let settings = resume_test_settings(&resume_dir);
11602            let session = SessionHandle::start(settings).await.unwrap();
11603            for i in 0u8..3 {
11604                let data = vec![i.wrapping_add(0xC0); 16384];
11605                let meta = make_test_torrent(&data, 16384);
11606                let storage = make_storage(&data, 16384);
11607                session
11608                    .add_torrent_with_meta(meta.into(), Some(storage))
11609                    .await
11610                    .unwrap();
11611            }
11612            tokio::time::sleep(Duration::from_millis(100)).await;
11613            session.save_resume_state().await.unwrap();
11614            session.shutdown().await.unwrap();
11615        }
11616
11617        // Phase 2: restart with queueing DISABLED.
11618        {
11619            let mut settings = resume_test_settings(&resume_dir);
11620            settings.queueing_enabled = false;
11621            let session = SessionHandle::start(settings).await.unwrap();
11622            tokio::time::sleep(Duration::from_millis(200)).await;
11623
11624            let list = session.list_torrent_summaries().await.unwrap();
11625            let queued = list
11626                .iter()
11627                .filter(|t| t.state == TorrentState::Queued)
11628                .count();
11629            assert_eq!(
11630                queued, 0,
11631                "with queueing disabled, no torrents should be Queued"
11632            );
11633            session.shutdown().await.unwrap();
11634        }
11635    }
11636
11637    #[tokio::test]
11638    async fn checking_complete_triggers_immediate_eval() {
11639        use crate::alert::AlertKind;
11640
11641        let mut settings = test_settings();
11642        settings.queueing_enabled = true;
11643        settings.active_checking = 1;
11644        settings.active_downloads = 5;
11645        settings.active_seeds = 5;
11646        settings.active_limit = 10;
11647        settings.auto_manage_interval = 300;
11648        let session = SessionHandle::start(settings).await.unwrap();
11649        let mut alerts = session.subscribe();
11650
11651        // Add 3 small torrents with correct data so checking completes.
11652        let mut hashes = Vec::new();
11653        for i in 0u8..3 {
11654            let data = vec![i.wrapping_add(0xD0); 16384];
11655            let meta = make_test_torrent(&data, 16384);
11656            let storage = make_storage(&data, 16384);
11657            let h = session
11658                .add_torrent_with_meta(meta.into(), Some(storage))
11659                .await
11660                .unwrap();
11661            hashes.push(h);
11662        }
11663
11664        // Wait for at least one checking-complete state change. The trigger
11665        // should cause evaluate_queue to promote the next candidate without
11666        // waiting for the 300s auto_manage_interval.
11667        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11668        let mut saw_checking_transition = false;
11669        while tokio::time::Instant::now() < deadline {
11670            if let Ok(Ok(alert)) =
11671                tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11672                && matches!(
11673                    alert.kind,
11674                    AlertKind::StateChanged {
11675                        prev_state: TorrentState::Checking,
11676                        ..
11677                    }
11678                )
11679            {
11680                saw_checking_transition = true;
11681                break;
11682            }
11683        }
11684
11685        assert!(
11686            saw_checking_transition,
11687            "should have seen a Checking→* state transition"
11688        );
11689
11690        // After the checking-complete trigger, the evaluator should have
11691        // promoted another torrent. Give a moment for the evaluate_queue
11692        // triggered by the alert arm to run.
11693        tokio::time::sleep(Duration::from_millis(200)).await;
11694
11695        let list = session.list_torrent_summaries().await.unwrap();
11696        let active = list
11697            .iter()
11698            .filter(|t| t.state != TorrentState::Queued)
11699            .count();
11700        assert!(
11701            active >= 1,
11702            "at least one torrent should be active after checking-complete trigger"
11703        );
11704
11705        session.shutdown().await.unwrap();
11706    }
11707
11708    // ---- P2C5: Restore queue position from resume data tests ----
11709
11710    #[tokio::test]
11711    async fn resume_restores_queue_position() {
11712        let tmp = tempfile::TempDir::new().unwrap();
11713        let resume_dir = tmp.path().to_path_buf();
11714
11715        let data = vec![0xF0; 16384];
11716        let meta = make_test_torrent(&data, 16384);
11717        let info_hash = meta.info_hash;
11718
11719        // Phase 1: add torrent, set queue position, save resume.
11720        {
11721            let settings = resume_test_settings(&resume_dir);
11722            let session = SessionHandle::start(settings).await.unwrap();
11723            let storage = make_storage(&data, 16384);
11724            session
11725                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11726                .await
11727                .unwrap();
11728            session.set_queue_position(info_hash, 3).await.unwrap();
11729            tokio::time::sleep(Duration::from_millis(100)).await;
11730            session.save_resume_state().await.unwrap();
11731            session.shutdown().await.unwrap();
11732        }
11733
11734        // Phase 2: restart and verify position survived.
11735        {
11736            let settings = resume_test_settings(&resume_dir);
11737            let session = SessionHandle::start(settings).await.unwrap();
11738            tokio::time::sleep(Duration::from_millis(200)).await;
11739
11740            let pos = session.queue_position(info_hash).await.unwrap();
11741            // Renormalization reassigns 0..N-1; with a single torrent
11742            // the position is 0 regardless of saved value.
11743            assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11744            session.shutdown().await.unwrap();
11745        }
11746    }
11747
11748    #[tokio::test]
11749    async fn resume_restores_auto_managed_false() {
11750        let tmp = tempfile::TempDir::new().unwrap();
11751        let resume_dir = tmp.path().to_path_buf();
11752
11753        let data = vec![0xF1; 16384];
11754        let meta = make_test_torrent(&data, 16384);
11755        let info_hash = meta.info_hash;
11756
11757        // Phase 1: add torrent, disable auto-manage, save resume.
11758        {
11759            let settings = resume_test_settings(&resume_dir);
11760            let session = SessionHandle::start(settings).await.unwrap();
11761            let storage = make_storage(&data, 16384);
11762            session
11763                .add_torrent_with_meta(meta.clone().into(), Some(storage))
11764                .await
11765                .unwrap();
11766            // Currently there's no direct API to set auto_managed on
11767            // SessionHandle — it's internal to TorrentEntry. Verify that
11768            // the resume round-trip preserves the default (true → 1).
11769            tokio::time::sleep(Duration::from_millis(100)).await;
11770            session.save_resume_state().await.unwrap();
11771            session.shutdown().await.unwrap();
11772        }
11773
11774        // Manually patch the resume file to set auto_managed=0.
11775        {
11776            let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11777            if path.exists() {
11778                let bytes = std::fs::read(&path).unwrap();
11779                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11780                rd.auto_managed = 0;
11781                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11782                std::fs::write(&path, patched).unwrap();
11783            }
11784        }
11785
11786        // Phase 2: restart and verify auto_managed was restored as false.
11787        {
11788            let settings = resume_test_settings(&resume_dir);
11789            let session = SessionHandle::start(settings).await.unwrap();
11790            tokio::time::sleep(Duration::from_millis(200)).await;
11791
11792            let stats = session.torrent_stats(info_hash).await.unwrap();
11793            assert!(
11794                !stats.auto_managed,
11795                "auto_managed should be false after restore"
11796            );
11797            session.shutdown().await.unwrap();
11798        }
11799    }
11800
11801    #[tokio::test]
11802    async fn resume_renormalizes_duplicate_positions() {
11803        let tmp = tempfile::TempDir::new().unwrap();
11804        let resume_dir = tmp.path().to_path_buf();
11805
11806        // Phase 1: add 3 torrents, save resume.
11807        let mut hashes = Vec::new();
11808        {
11809            let settings = resume_test_settings(&resume_dir);
11810            let session = SessionHandle::start(settings).await.unwrap();
11811            for i in 0u8..3 {
11812                let data = vec![i.wrapping_add(0xE0); 16384];
11813                let meta = make_test_torrent(&data, 16384);
11814                let storage = make_storage(&data, 16384);
11815                let h = session
11816                    .add_torrent_with_meta(meta.into(), Some(storage))
11817                    .await
11818                    .unwrap();
11819                hashes.push(h);
11820            }
11821            tokio::time::sleep(Duration::from_millis(100)).await;
11822            session.save_resume_state().await.unwrap();
11823            session.shutdown().await.unwrap();
11824        }
11825
11826        // Manually patch ALL resume files to have queue_position=0.
11827        for hash in &hashes {
11828            let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11829            if path.exists() {
11830                let bytes = std::fs::read(&path).unwrap();
11831                let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11832                rd.queue_position = 0;
11833                let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11834                std::fs::write(&path, patched).unwrap();
11835            }
11836        }
11837
11838        // Phase 2: restart and verify positions are contiguous 0,1,2.
11839        {
11840            let settings = resume_test_settings(&resume_dir);
11841            let session = SessionHandle::start(settings).await.unwrap();
11842            tokio::time::sleep(Duration::from_millis(200)).await;
11843
11844            let mut positions = Vec::new();
11845            for hash in &hashes {
11846                if let Ok(pos) = session.queue_position(*hash).await {
11847                    positions.push(pos);
11848                }
11849            }
11850            positions.sort_unstable();
11851            let expected: Vec<i32> = (0..positions.len() as i32).collect();
11852            assert_eq!(
11853                positions, expected,
11854                "positions should be contiguous 0..N-1 after renormalization"
11855            );
11856            session.shutdown().await.unwrap();
11857        }
11858    }
11859
11860    // ---- P2C6: EWMA rate smoothing tests ----
11861
11862    #[test]
11863    fn ewma_smooths_transient_drop() {
11864        let alpha = 0.3_f64;
11865        let prev = 100_000.0_f64;
11866        let sample = 0.0_f64;
11867        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11868        assert!(
11869            (smoothed - 70_000.0).abs() < 1.0,
11870            "smoothed rate should be ~70000, got {smoothed}"
11871        );
11872    }
11873
11874    #[test]
11875    fn ewma_alpha_one_equals_raw() {
11876        let alpha = 1.0_f64;
11877        let prev = 100_000.0_f64;
11878        let sample = 42_000.0_f64;
11879        let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11880        assert!(
11881            (smoothed - sample).abs() < 0.001,
11882            "alpha=1.0 should produce raw rate, got {smoothed}"
11883        );
11884    }
11885
11886    // ---- P2C7: Configurable seed anti-flap duration tests ----
11887
11888    #[test]
11889    fn seed_anti_flap_uses_longer_duration() {
11890        let seed_queue_min_active_secs = 1800_u64;
11891        let auto_manage_startup = 60_u64;
11892        let started_5_min_ago = std::time::Duration::from_mins(5);
11893        let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11894
11895        // Seeding torrent started 5 min ago: still recently_started
11896        // with seed_queue_min_active_secs=1800.
11897        assert!(
11898            started_5_min_ago < seed_duration,
11899            "5 min < 30 min, seeding torrent should be recently_started"
11900        );
11901
11902        // Downloading torrent started 5 min ago: NOT recently_started
11903        // with auto_manage_startup=60.
11904        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11905        assert!(
11906            started_5_min_ago > dl_duration,
11907            "5 min > 60s, downloading torrent should NOT be recently_started"
11908        );
11909    }
11910
11911    #[test]
11912    fn download_anti_flap_uses_startup_duration() {
11913        let auto_manage_startup = 60_u64;
11914        let started_5_min_ago = std::time::Duration::from_mins(5);
11915        let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11916        assert!(
11917            started_5_min_ago > dl_duration,
11918            "downloading torrent started 5 min ago should NOT be recently_started"
11919        );
11920    }
11921
11922    // ── M214: classify round-trip for Connection + Speed fields ──────
11923
11924    #[test]
11925    fn classify_restart_required_upnp_change() {
11926        let old = Settings::default();
11927        let mut new = old.clone();
11928        new.enable_upnp = !old.enable_upnp;
11929        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11930        assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11931    }
11932
11933    #[test]
11934    fn classify_restart_required_natpmp_change() {
11935        let old = Settings::default();
11936        let mut new = old.clone();
11937        new.enable_natpmp = !old.enable_natpmp;
11938        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11939        assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11940    }
11941
11942    #[test]
11943    fn classify_immediate_max_connec_global_change() {
11944        let old = Settings::default();
11945        let mut new = old.clone();
11946        new.max_connections_global = if old.max_connections_global == 500 {
11947            501
11948        } else {
11949            500
11950        };
11951        assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11952        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11953    }
11954
11955    #[test]
11956    fn classify_immediate_max_uploads_per_torrent_change() {
11957        // M224: per-torrent upload slot cap is classified immediate; the
11958        // choker reads the new cap at its next unchoke tick. Mirrors the
11959        // max_connec_global pattern from M214.
11960        let old = Settings::default();
11961        let mut new = old.clone();
11962        new.max_uploads_per_torrent = 4;
11963        assert_eq!(
11964            classify_immediate(&old, &new),
11965            vec!["max_uploads_per_torrent"]
11966        );
11967        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11968    }
11969
11970    #[test]
11971    fn classify_restart_required_proxy_type_change() {
11972        let old = Settings::default();
11973        let mut new = old.clone();
11974        new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11975        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11976        assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11977    }
11978
11979    #[test]
11980    fn classify_restart_required_proxy_credentials_change() {
11981        let old = Settings::default();
11982        let mut new = old.clone();
11983        new.proxy.username = Some("alice".into());
11984        new.proxy.password = Some("secret".into());
11985        assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11986        let restart = classify_restart_required(&old, &new);
11987        // Both fields must surface; order is implementation-defined but the
11988        // set must equal {proxy_username, proxy_password}.
11989        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11990        assert_eq!(
11991            set,
11992            ["proxy_username", "proxy_password"]
11993                .into_iter()
11994                .collect::<std::collections::HashSet<_>>()
11995        );
11996    }
11997
11998    #[test]
11999    fn classify_combined_immediate_and_restart() {
12000        // Multi-field diff: max_connec_global + max_uploads_per_torrent
12001        // (both immediate) + upnp + proxy_type (both restart) should populate
12002        // both lists. M224 extends the immediate side.
12003        let old = Settings::default();
12004        let mut new = old.clone();
12005        new.max_connections_global = old.max_connections_global + 1;
12006        new.max_uploads_per_torrent = 4;
12007        new.enable_upnp = !old.enable_upnp;
12008        new.proxy.proxy_type = crate::proxy::ProxyType::Http;
12009
12010        let immediate = classify_immediate(&old, &new);
12011        let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
12012        assert_eq!(
12013            imm_set,
12014            ["max_connec_global", "max_uploads_per_torrent"]
12015                .into_iter()
12016                .collect::<std::collections::HashSet<_>>()
12017        );
12018        let restart = classify_restart_required(&old, &new);
12019        let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
12020        assert_eq!(
12021            set,
12022            ["upnp", "proxy_type"]
12023                .into_iter()
12024                .collect::<std::collections::HashSet<_>>()
12025        );
12026    }
12027
12028    // ── M215: BitTorrent + Advanced classify-list verification ──────
12029
12030    #[test]
12031    fn classify_immediate_seed_time_limit_change() {
12032        let old = Settings::default();
12033        let mut new = old.clone();
12034        new.seed_time_limit_secs = Some(3600);
12035        assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
12036        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12037    }
12038
12039    #[test]
12040    fn classify_immediate_inactive_seed_time_limit_change() {
12041        let old = Settings::default();
12042        let mut new = old.clone();
12043        new.inactive_seed_time_limit_secs = Some(1800);
12044        assert_eq!(
12045            classify_immediate(&old, &new),
12046            vec!["max_inactive_seeding_time"]
12047        );
12048        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12049    }
12050
12051    // M225: `classify_restart_required_hashing_threads_change` and
12052    // `classify_restart_required_save_resume_interval_change` (formerly here)
12053    // were DELETED in M225 — those fields are now `classify_immediate` per
12054    // OV F2c / F4. Replacement immediate-dispatch coverage follows.
12055
12056    #[test]
12057    fn classify_immediate_save_resume_interval_change() {
12058        // M225 Step 1: save_resume_interval graduated from restart_required
12059        // to immediate. SessionActor rebuilds the resume_save_interval timer
12060        // via Arc<Notify> on dispatch — no daemon restart needed.
12061        let old = Settings::default();
12062        let mut new = old.clone();
12063        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
12064        assert_eq!(classify_immediate(&old, &new), vec!["save_resume_interval"]);
12065        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12066    }
12067
12068    #[test]
12069    fn classify_immediate_hashing_threads_change() {
12070        // M225 Step 2: hashing_threads graduated from restart_required to
12071        // immediate. The per-torrent piece-verify batch reads
12072        // self.config.hashing_threads at the start of each batch, so a value
12073        // change applies on the NEXT batch via the existing
12074        // TorrentCommand::UpdateSettings(SettingsDelta) fan-out path.
12075        let old = Settings::default();
12076        let mut new = old.clone();
12077        new.hashing_threads = old.hashing_threads.saturating_add(2);
12078        assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
12079        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12080    }
12081
12082    #[test]
12083    fn classify_immediate_ip_filter_enabled_change() {
12084        // M225 Step 3: ip_filter_enabled graduated to immediate. apply_settings
12085        // writes through Arc<RwLock<IpFilter>> with self.ip_filter.write().enabled
12086        // = enabled; future is_blocked calls observe the new value on the next
12087        // RwLock read.
12088        let old = Settings::default();
12089        let mut new = old.clone();
12090        new.ip_filter_enabled = !old.ip_filter_enabled;
12091        assert_eq!(classify_immediate(&old, &new), vec!["ip_filter_enabled"]);
12092        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12093    }
12094
12095    #[test]
12096    fn settings_delta_from_diff_includes_save_resume_interval() {
12097        // M225 Step 1: SettingsDelta carries save_resume_interval_secs so
12098        // apply_settings can dispatch the Notify on observed change.
12099        use crate::types::SettingsDelta;
12100        let old = Settings::default();
12101        let mut new = old.clone();
12102        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
12103        let d = SettingsDelta::from_diff(&old, &new);
12104        assert_eq!(
12105            d.save_resume_interval_secs,
12106            Some(new.save_resume_interval_secs)
12107        );
12108        assert!(d.hashing_threads.is_none());
12109        assert!(d.ip_filter_enabled.is_none());
12110        assert!(!d.is_empty());
12111    }
12112
12113    #[test]
12114    fn settings_delta_from_diff_includes_hashing_threads() {
12115        // M225 Step 2: SettingsDelta carries hashing_threads so the existing
12116        // TorrentCommand::UpdateSettings fan-out propagates per-torrent.
12117        use crate::types::SettingsDelta;
12118        let old = Settings::default();
12119        let mut new = old.clone();
12120        new.hashing_threads = old.hashing_threads.saturating_add(1);
12121        let d = SettingsDelta::from_diff(&old, &new);
12122        assert_eq!(d.hashing_threads, Some(new.hashing_threads));
12123        assert!(d.save_resume_interval_secs.is_none());
12124        assert!(d.ip_filter_enabled.is_none());
12125        assert!(!d.is_empty());
12126    }
12127
12128    #[test]
12129    fn settings_delta_from_diff_includes_ip_filter_enabled() {
12130        // M225 Step 3: SettingsDelta carries ip_filter_enabled. apply_settings
12131        // applies it through the outer Arc<RwLock<IpFilter>> write-lock.
12132        use crate::types::SettingsDelta;
12133        let old = Settings::default();
12134        let mut new = old.clone();
12135        new.ip_filter_enabled = !old.ip_filter_enabled;
12136        let d = SettingsDelta::from_diff(&old, &new);
12137        assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
12138        assert!(d.save_resume_interval_secs.is_none());
12139        assert!(d.hashing_threads.is_none());
12140        assert!(!d.is_empty());
12141    }
12142
12143    #[test]
12144    fn settings_delta_is_empty_honours_m225_fields() {
12145        // M225: is_empty must return false when any M225 field is set so the
12146        // fan-out path runs.
12147        use crate::types::SettingsDelta;
12148        let mut d = SettingsDelta::default();
12149        assert!(d.is_empty());
12150        d.save_resume_interval_secs = Some(120);
12151        assert!(!d.is_empty());
12152        d = SettingsDelta::default();
12153        d.hashing_threads = Some(8);
12154        assert!(!d.is_empty());
12155        d = SettingsDelta::default();
12156        d.ip_filter_enabled = Some(false);
12157        assert!(!d.is_empty());
12158    }
12159
12160    // ── M226: SettingsDelta + classify_immediate coverage ──────────────────
12161
12162    /// Helper for compact M226 delta+classify tests: toggle one field, verify
12163    /// the delta picks it up AND [`classify_immediate`] yields the expected
12164    /// alias AND [`classify_restart_required`] stays empty.
12165    fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
12166    where
12167        F: FnOnce(&mut Settings),
12168    {
12169        use crate::types::SettingsDelta;
12170        let old = Settings::default();
12171        let mut new = old.clone();
12172        mutate(&mut new);
12173        let d = SettingsDelta::from_diff(&old, &new);
12174        assert!(
12175            !d.is_empty(),
12176            "{alias}: delta must not be empty after toggle"
12177        );
12178        let imm = classify_immediate(&old, &new);
12179        assert!(
12180            imm.contains(&alias),
12181            "{alias}: classify_immediate must contain alias, got {imm:?}"
12182        );
12183        let rr = classify_restart_required(&old, &new);
12184        assert!(
12185            !rr.contains(&alias),
12186            "{alias}: must NOT appear in classify_restart_required"
12187        );
12188    }
12189
12190    #[test]
12191    fn m226_notify_on_complete_immediate() {
12192        m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
12193    }
12194
12195    #[test]
12196    fn m226_notify_on_error_immediate() {
12197        m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
12198    }
12199
12200    #[test]
12201    fn m226_on_complete_program_immediate() {
12202        m226_delta_and_classify_check(
12203            |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
12204            "on_complete_program",
12205        );
12206    }
12207
12208    #[test]
12209    fn m226_use_incomplete_dir_immediate() {
12210        m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
12211    }
12212
12213    #[test]
12214    fn m226_incomplete_dir_immediate() {
12215        m226_delta_and_classify_check(
12216            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
12217            "incomplete_dir",
12218        );
12219    }
12220
12221    #[test]
12222    fn m226_default_skip_hash_check_immediate() {
12223        m226_delta_and_classify_check(
12224            |s| s.default_skip_hash_check = true,
12225            "default_skip_hash_check",
12226        );
12227    }
12228
12229    #[test]
12230    fn m226_incomplete_extension_enabled_immediate() {
12231        // Default is TRUE so we toggle false to observe diff.
12232        m226_delta_and_classify_check(
12233            |s| s.incomplete_extension_enabled = false,
12234            "incomplete_extension_enabled",
12235        );
12236    }
12237
12238    #[test]
12239    fn m226_watched_folder_immediate() {
12240        m226_delta_and_classify_check(
12241            |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
12242            "watched_folder",
12243        );
12244    }
12245
12246    #[test]
12247    fn m226_delete_torrent_after_add_immediate() {
12248        m226_delta_and_classify_check(
12249            |s| s.delete_torrent_after_add = true,
12250            "delete_torrent_after_add",
12251        );
12252    }
12253
12254    #[test]
12255    fn m226_move_completed_enabled_immediate() {
12256        m226_delta_and_classify_check(
12257            |s| s.move_completed_enabled = true,
12258            "move_completed_enabled",
12259        );
12260    }
12261
12262    #[test]
12263    fn m226_move_completed_to_immediate() {
12264        m226_delta_and_classify_check(
12265            |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
12266            "move_completed_to",
12267        );
12268    }
12269
12270    #[test]
12271    fn m226_ip_filter_auto_refresh_immediate() {
12272        m226_delta_and_classify_check(
12273            |s| s.ip_filter_auto_refresh = true,
12274            "ip_filter_auto_refresh",
12275        );
12276    }
12277
12278    #[test]
12279    fn m226_web_ui_https_enabled_immediate() {
12280        m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
12281    }
12282
12283    #[test]
12284    fn m226_network_interface_immediate() {
12285        m226_delta_and_classify_check(
12286            |s| s.network_interface = Some("eth0".into()),
12287            "network_interface",
12288        );
12289    }
12290
12291    #[test]
12292    fn m226_default_add_paused_immediate() {
12293        m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
12294    }
12295
12296    #[test]
12297    fn m257c_request_budget_per_torrent_immediate() {
12298        m226_delta_and_classify_check(
12299            |s| s.request_budget_per_torrent = 0,
12300            "request_budget_per_torrent",
12301        );
12302    }
12303
12304    #[test]
12305    fn m257c_request_budget_floor_immediate() {
12306        m226_delta_and_classify_check(|s| s.request_budget_floor = 16, "request_budget_floor");
12307    }
12308
12309    #[test]
12310    fn m226_delta_clears_optional_path_incomplete_dir() {
12311        // F4 — outer Some + inner None means "clear to None". Without nested
12312        // Option this case is indistinguishable from "no change".
12313        use crate::types::SettingsDelta;
12314        let old = Settings {
12315            incomplete_dir: Some(std::path::PathBuf::from("/foo")),
12316            ..Settings::default()
12317        };
12318        let new = Settings {
12319            incomplete_dir: None,
12320            ..old.clone()
12321        };
12322        let d = SettingsDelta::from_diff(&old, &new);
12323        assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
12324        assert!(!d.is_empty());
12325    }
12326
12327    #[test]
12328    fn m226_delta_clears_optional_path_watched_folder() {
12329        // F4 — same pattern for watched_folder.
12330        use crate::types::SettingsDelta;
12331        let old = Settings {
12332            watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
12333            ..Settings::default()
12334        };
12335        let new = Settings {
12336            watched_folder: None,
12337            ..old.clone()
12338        };
12339        let d = SettingsDelta::from_diff(&old, &new);
12340        assert_eq!(d.watched_folder, Some(None));
12341        assert!(!d.is_empty());
12342    }
12343
12344    #[test]
12345    fn m226_delta_is_empty_honours_new_fields() {
12346        // is_empty must return false when ANY M226 field is set.
12347        use crate::types::SettingsDelta;
12348        let mut d = SettingsDelta::default();
12349        assert!(d.is_empty());
12350        d.notify_on_complete = Some(true);
12351        assert!(!d.is_empty());
12352        d = SettingsDelta::default();
12353        d.watched_folder = Some(None); // clear-to-None still counts
12354        assert!(!d.is_empty());
12355        d = SettingsDelta::default();
12356        d.default_add_paused = Some(true);
12357        assert!(!d.is_empty());
12358    }
12359
12360    #[test]
12361    fn m226_no_fields_appear_in_restart_required() {
12362        // Negative coverage: toggling each of the 15 M226 fields one at a
12363        // time must NOT produce any classify_restart_required entries.
12364        type Mutation = fn(&mut Settings);
12365        let mutations: [Mutation; 15] = [
12366            |s| s.notify_on_complete = true,
12367            |s| s.notify_on_error = true,
12368            |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
12369            |s| s.use_incomplete_dir = true,
12370            |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
12371            |s| s.default_skip_hash_check = true,
12372            |s| s.incomplete_extension_enabled = false,
12373            |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
12374            |s| s.delete_torrent_after_add = true,
12375            |s| s.move_completed_enabled = true,
12376            |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
12377            |s| s.ip_filter_auto_refresh = true,
12378            |s| s.web_ui_https_enabled = true,
12379            |s| s.network_interface = Some("eth0".into()),
12380            |s| s.default_add_paused = true,
12381        ];
12382        let old = Settings::default();
12383        for (idx, m) in mutations.iter().enumerate() {
12384            let mut new = old.clone();
12385            m(&mut new);
12386            let rr = classify_restart_required(&old, &new);
12387            assert!(
12388                rr.is_empty(),
12389                "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
12390            );
12391        }
12392    }
12393
12394    #[test]
12395    fn classify_immediate_seed_time_and_inactive_combined() {
12396        // Both seed-time limits flipped in one delta — both must surface
12397        // in `immediate`, none in `restart_required`.
12398        let old = Settings::default();
12399        let mut new = old.clone();
12400        new.seed_time_limit_secs = Some(7200);
12401        new.inactive_seed_time_limit_secs = Some(900);
12402        let imm = classify_immediate(&old, &new);
12403        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12404        assert_eq!(
12405            set,
12406            ["max_seeding_time", "max_inactive_seeding_time"]
12407                .into_iter()
12408                .collect::<std::collections::HashSet<_>>()
12409        );
12410        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12411    }
12412
12413    #[test]
12414    fn classify_combined_seed_time_and_hashing_both_immediate() {
12415        // M225: hashing_threads graduated from restart_required to immediate
12416        // (D-eng-2 revised). With seed_time_limit also immediate, both fields
12417        // must surface in immediate, none in restart_required.
12418        let old = Settings::default();
12419        let mut new = old.clone();
12420        new.seed_time_limit_secs = Some(1200);
12421        new.hashing_threads = old.hashing_threads.saturating_add(2);
12422        let imm = classify_immediate(&old, &new);
12423        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12424        assert_eq!(
12425            set,
12426            ["max_seeding_time", "hashing_threads"]
12427                .into_iter()
12428                .collect::<std::collections::HashSet<_>>()
12429        );
12430        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12431    }
12432
12433    #[test]
12434    fn classify_combined_hashing_and_save_resume_both_immediate() {
12435        // M225: hashing_threads and save_resume_interval both graduated from
12436        // restart_required to immediate. Mixed change must land both in
12437        // immediate, none in restart_required.
12438        let old = Settings::default();
12439        let mut new = old.clone();
12440        new.hashing_threads = old.hashing_threads.saturating_add(3);
12441        new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
12442        let imm = classify_immediate(&old, &new);
12443        let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
12444        assert_eq!(
12445            set,
12446            ["hashing_threads", "save_resume_interval"]
12447                .into_iter()
12448                .collect::<std::collections::HashSet<_>>()
12449        );
12450        assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
12451    }
12452
12453    // ── M226: AddTorrentParams.paused: Option<bool> resolution ──────────
12454    //
12455    // The constructor default `paused: None` means "honour the engine's
12456    // `default_add_paused`"; an explicit `.paused(v)` call wraps `Some(v)`
12457    // and wins over the engine setting. Tests exercise all 4 combinations
12458    // (3 here + 1 implicit default already covered by existing
12459    // `pause_resume_via_session` and `add_torrent_with_meta` tests).
12460
12461    /// Re-bencode a single-piece v1 torrent so the bytes branch of
12462    /// `add_torrent` can ingest it. Helper avoids dragging
12463    /// `add_torrent_with_meta` into the M226 tests (that bypasses
12464    /// `AddTorrentParams` entirely).
12465    fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
12466        use serde::Serialize;
12467
12468        #[derive(Serialize)]
12469        struct Info<'a> {
12470            length: u64,
12471            name: &'a str,
12472            #[serde(rename = "piece length")]
12473            piece_length: u64,
12474            #[serde(with = "serde_bytes")]
12475            pieces: &'a [u8],
12476        }
12477        #[derive(Serialize)]
12478        struct Torrent<'a> {
12479            info: Info<'a>,
12480        }
12481
12482        let mut pieces = Vec::new();
12483        let mut offset = 0;
12484        while offset < data.len() {
12485            let end = (offset + piece_length as usize).min(data.len());
12486            let hash = irontide_core::sha1(&data[offset..end]);
12487            pieces.extend_from_slice(hash.as_bytes());
12488            offset = end;
12489        }
12490
12491        irontide_bencode::to_bytes(&Torrent {
12492            info: Info {
12493                length: data.len() as u64,
12494                name: "m226-test",
12495                piece_length,
12496                pieces: &pieces,
12497            },
12498        })
12499        .unwrap()
12500    }
12501
12502    /// M226 D1 acceptance: `default_add_paused = true` + caller passes no
12503    /// explicit `.paused(...)` → torrent must land paused.
12504    #[tokio::test]
12505    async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
12506        let mut settings = test_settings();
12507        settings.default_add_paused = true;
12508        let session = SessionHandle::start(settings).await.unwrap();
12509
12510        let data = vec![0xAB; 16384];
12511        let bytes = m226_make_torrent_bytes(&data, 16384);
12512        let info_hash = session
12513            .add_torrent(AddTorrentParams::bytes(bytes))
12514            .await
12515            .unwrap();
12516
12517        // Pause is dispatched via `tokio::spawn(handle.pause())`; give the
12518        // dispatched task a tick to land before we read state.
12519        tokio::time::sleep(Duration::from_millis(100)).await;
12520        let stats = session.torrent_stats(info_hash).await.unwrap();
12521        assert_eq!(
12522            stats.state,
12523            TorrentState::Paused,
12524            "engine default_add_paused=true must pause the torrent when caller \
12525             passes AddTorrentParams::bytes() without an explicit .paused(...)"
12526        );
12527
12528        session.shutdown().await.unwrap();
12529    }
12530
12531    /// M226 D1 acceptance: explicit `.paused(false)` must beat
12532    /// `default_add_paused = true` — the engine setting is the fallback,
12533    /// the per-call override is authoritative.
12534    #[tokio::test]
12535    async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
12536        let mut settings = test_settings();
12537        settings.default_add_paused = true;
12538        let session = SessionHandle::start(settings).await.unwrap();
12539
12540        let data = vec![0xCD; 16384];
12541        let bytes = m226_make_torrent_bytes(&data, 16384);
12542        let info_hash = session
12543            .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
12544            .await
12545            .unwrap();
12546
12547        // Negative assertion: nothing should run a paused dispatch path —
12548        // a brief sleep guards against a phantom spawned pause.
12549        tokio::time::sleep(Duration::from_millis(100)).await;
12550        let stats = session.torrent_stats(info_hash).await.unwrap();
12551        assert_ne!(
12552            stats.state,
12553            TorrentState::Paused,
12554            "explicit .paused(false) must override default_add_paused=true; \
12555             got state={:?}",
12556            stats.state
12557        );
12558
12559        session.shutdown().await.unwrap();
12560    }
12561
12562    /// M226 D1 acceptance: explicit `.paused(true)` must beat
12563    /// `default_add_paused = false` — mirror image of the previous test
12564    /// to cover the other direction of "explicit wins over default".
12565    #[tokio::test]
12566    async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
12567        let mut settings = test_settings();
12568        settings.default_add_paused = false;
12569        let session = SessionHandle::start(settings).await.unwrap();
12570
12571        let data = vec![0xEF; 16384];
12572        let bytes = m226_make_torrent_bytes(&data, 16384);
12573        let info_hash = session
12574            .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
12575            .await
12576            .unwrap();
12577
12578        tokio::time::sleep(Duration::from_millis(100)).await;
12579        let stats = session.torrent_stats(info_hash).await.unwrap();
12580        assert_eq!(
12581            stats.state,
12582            TorrentState::Paused,
12583            "explicit .paused(true) must pause even when \
12584             default_add_paused=false"
12585        );
12586
12587        session.shutdown().await.unwrap();
12588    }
12589}