Skip to main content

irontide_engine/
types.rs

1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use bytes::Bytes;
6use tokio::sync::oneshot;
7
8use irontide_storage::Bitfield;
9use irontide_wire::ExtHandshake;
10
11// Payload + vocabulary types relocated to `irontide-session-types` at M244a
12// (types.rs god-module decomposition). Re-exported here so `crate::types::*`
13// paths resolve unchanged for internal callers and the `irontide_session`
14// public facade; the actor message types below (`PeerEvent` / `PeerCommand` /
15// `TorrentCommand` / `BlockEntry`) stay until the M244b/M244c actor split.
16pub use irontide_session_types::{
17    FileMode, FileStatus, PartialPieceInfo, PeerConnectionKind, PeerInfo, SessionStats,
18    SettingsDelta, TorrentConfig, TorrentFlags, TorrentState, TorrentStats,
19};
20// `TorrentSummary` is referenced only by engine's inline unit tests via
21// `crate::types::…`; production engine code uses `irontide_session_types::…`
22// directly. Gate the re-export to `#[cfg(test)]` so neither the lib nor the
23// test build flags it unused under `-D warnings`. (The other session-types
24// payloads have no engine-internal `crate::types::` consumer post-M244b split.)
25#[cfg(test)]
26pub use irontide_session_types::TorrentSummary;
27
28/// Lightweight record of a single block write completion,
29/// carried in `PeerEvent::PieceBlocksBatch`.
30#[derive(Debug, Clone)]
31pub(crate) struct BlockEntry {
32    pub index: u32,  // piece index
33    pub begin: u32,  // byte offset within piece
34    pub length: u32, // block size (usually 16384)
35    /// M257f: request→receipt round-trip measured by the peer task
36    /// (the only component that sees both directions post-M104). Feeds
37    /// the actor-side `avg_rtt` EWMA that the BDP depth caps read.
38    /// `None` when the send was not tracked (re-request races, legacy
39    /// `PieceData` path).
40    pub rtt: Option<std::time::Duration>,
41}
42
43/// Events sent from a `PeerTask` back to the `TorrentActor`.
44#[derive(Debug)]
45#[allow(dead_code)] // consumed by peer/torrent modules (not yet implemented)
46pub(crate) enum PeerEvent {
47    Bitfield {
48        peer_addr: SocketAddr,
49        bitfield: Bitfield,
50    },
51    Have {
52        peer_addr: SocketAddr,
53        index: u32,
54    },
55    /// BEP 54: Peer no longer has a piece (`lt_donthave` extension).
56    DontHave {
57        peer_addr: SocketAddr,
58        index: u32,
59    },
60    PieceData {
61        peer_addr: SocketAddr,
62        index: u32,
63        begin: u32,
64        data: Bytes,
65    },
66    /// Block completion from a peer task's direct disk write.
67    /// Sent immediately on each block write for real-time `TorrentActor` visibility.
68    PieceBlocksBatch {
69        peer_addr: SocketAddr,
70        blocks: Vec<BlockEntry>,
71    },
72    PeerChoking {
73        peer_addr: SocketAddr,
74        choking: bool,
75    },
76    PeerInterested {
77        peer_addr: SocketAddr,
78        interested: bool,
79    },
80    ExtHandshake {
81        peer_addr: SocketAddr,
82        handshake: ExtHandshake,
83    },
84    MetadataPiece {
85        peer_addr: SocketAddr,
86        piece: u32,
87        data: Bytes,
88        total_size: u64,
89    },
90    MetadataReject {
91        peer_addr: SocketAddr,
92        piece: u32,
93    },
94    PexPeers {
95        new_peers: Vec<SocketAddr>,
96    },
97    TrackersReceived {
98        tracker_urls: Vec<String>,
99    },
100    IncomingRequest {
101        peer_addr: SocketAddr,
102        index: u32,
103        begin: u32,
104        length: u32,
105    },
106    RejectRequest {
107        peer_addr: SocketAddr,
108        index: u32,
109        begin: u32,
110        length: u32,
111    },
112    AllowedFast {
113        peer_addr: SocketAddr,
114        index: u32,
115    },
116    SuggestPiece {
117        peer_addr: SocketAddr,
118        index: u32,
119    },
120    /// Peer successfully connected with a specific transport.
121    TransportIdentified {
122        peer_addr: SocketAddr,
123        transport: crate::rate_limiter::PeerTransport,
124    },
125    /// M140: BT handshake completed successfully — peer is now truly live.
126    /// Sent from `run_peer` after BT protocol handshake exchange validates
127    /// `info_hash` and `peer_id`. Triggers `mark_live()` in the actor.
128    HandshakeComplete {
129        peer_addr: SocketAddr,
130        /// M174: Whether MSE/PE negotiated RC4 encryption for this connection.
131        is_encrypted: bool,
132    },
133    Disconnected {
134        peer_addr: SocketAddr,
135        reason: Option<String>,
136    },
137    WebSeedPieceData {
138        url: String,
139        index: u32,
140        data: Bytes,
141    },
142    WebSeedError {
143        url: String,
144        piece: u32,
145        message: String,
146    },
147    /// M178: Periodic per-URL progress update from `WebSeedTask`. Coalesced
148    /// by the task's 250 ms throttle (configurable via
149    /// `Settings::web_seed_progress_throttle_ms`); the actor accumulates
150    /// `WebSeedStats` from these. `error == Some(_)` records a transition
151    /// into the errored state; the field is reset to `None` on recovery
152    /// emissions but the accumulated `last_error` on `WebSeedStats`
153    /// persists per Issue 2.2.
154    WebSeedProgress {
155        url: String,
156        bytes: u64,
157        rate_bps: u64,
158        error: Option<String>,
159    },
160    /// M186: Web seed completed backoff and is ready for new piece assignments.
161    WebSeedRetryReady {
162        url: String,
163    },
164    /// M186: Web seed permanently failed after max consecutive failures.
165    WebSeedPermanentFailure {
166        url: String,
167    },
168    /// BEP 52: Received hash response from peer.
169    HashesReceived {
170        peer_addr: SocketAddr,
171        request: irontide_core::HashRequest,
172        hashes: Vec<irontide_core::Id32>,
173    },
174    /// BEP 52: Peer rejected our hash request.
175    HashRequestRejected {
176        peer_addr: SocketAddr,
177        request: irontide_core::HashRequest,
178    },
179    /// BEP 52: Peer sent a hash request to us.
180    IncomingHashRequest {
181        peer_addr: SocketAddr,
182        request: irontide_core::HashRequest,
183    },
184    /// BEP 55: Received a Rendezvous request (we are the relay).
185    HolepunchRendezvous {
186        peer_addr: SocketAddr,
187        target: SocketAddr,
188    },
189    /// BEP 55: Received a Connect message (we should initiate simultaneous connect).
190    HolepunchConnect {
191        peer_addr: SocketAddr,
192        target: SocketAddr,
193    },
194    /// BEP 55: Received an Error message from the relay.
195    HolepunchError {
196        peer_addr: SocketAddr,
197        target: SocketAddr,
198        error_code: u32,
199    },
200    /// MSE handshake failed — peer is being retried with a different encryption mode.
201    /// Carries the new command channel sender so the `TorrentActor` can
202    /// update its `PeerState`.
203    MseRetry {
204        peer_addr: SocketAddr,
205        cmd_tx: tokio::sync::mpsc::Sender<PeerCommand>,
206    },
207    /// Peer released a piece it was downloading (choke, error, disconnect).
208    PieceReleased {
209        peer_addr: SocketAddr,
210        piece: u32,
211    },
212    /// M187: Requester asks the actor to acquire a piece via `PieceTracker`.
213    /// Actor responds with the piece index via the oneshot, or `NoneAvailable`
214    /// if the peer has no dispatchable pieces.
215    AcquirePiece {
216        peer_addr: SocketAddr,
217        response_tx: tokio::sync::oneshot::Sender<crate::piece_reservation::AcquireResponse>,
218    },
219}
220
221/// Commands sent from the `TorrentActor` to a `PeerTask`.
222#[derive(Debug)]
223#[allow(dead_code)] // consumed by peer/torrent modules (not yet implemented)
224pub(crate) enum PeerCommand {
225    Request {
226        index: u32,
227        begin: u32,
228        length: u32,
229    },
230    Cancel {
231        index: u32,
232        begin: u32,
233        length: u32,
234    },
235    SetChoking(bool),
236    SetInterested(bool),
237    Have(u32),
238    RequestMetadata {
239        piece: u32,
240    },
241    RejectRequest {
242        index: u32,
243        begin: u32,
244        length: u32,
245    },
246    AllowedFast(u32),
247    SendPiece {
248        index: u32,
249        begin: u32,
250        data: Bytes,
251    },
252    /// Send an updated extension handshake (e.g. BEP 21 upload-only).
253    SendExtHandshake(irontide_wire::ExtHandshake),
254    /// BEP 6: Suggest a piece to the peer.
255    SuggestPiece(u32),
256    /// BEP 52: Send a hash request to the peer.
257    SendHashRequest(irontide_core::HashRequest),
258    /// BEP 52: Send hashes in response to a peer's request.
259    SendHashes {
260        request: irontide_core::HashRequest,
261        hashes: Vec<irontide_core::Id32>,
262    },
263    /// BEP 52: Reject a peer's hash request.
264    SendHashReject(irontide_core::HashRequest),
265    /// BEP 11: Send a PEX message to this peer.
266    SendPex {
267        message: crate::pex::PexMessage,
268    },
269    /// BEP 55: Send a holepunch message to this peer.
270    SendHolepunch(irontide_wire::HolepunchMessage),
271    /// Update the piece count after BEP 9 metadata assembly.
272    UpdateNumPieces(u32),
273    /// M159: Tell the peer task to stop dispatching block requests.
274    ///
275    /// Translated to `DispatchCommand::Stop` by the reader loop. The requester
276    /// transitions back to the idle state waiting for a fresh `StartRequesting`.
277    /// Uploads are unaffected.
278    StopRequesting,
279    /// M75: Actor sends reservation state to peer task for integrated dispatch.
280    /// Sent after metadata download (magnet) or at peer connection (non-magnet).
281    StartRequesting {
282        piece_notify: std::sync::Arc<tokio::sync::Notify>,
283        disk_handle: Option<crate::disk::DiskHandle>,
284        write_error_tx: tokio::sync::mpsc::Sender<crate::disk::DiskWriteError>,
285        lengths: irontide_core::Lengths,
286    },
287    Shutdown,
288}
289
290/// Helper trait combining [`AsyncRead`] + [`AsyncWrite`] for trait-object erasure.
291///
292/// Rust doesn't allow `dyn AsyncRead + AsyncWrite` directly, so this trait
293/// combines both into a single trait that can be used as a trait object.
294pub trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
295
296impl<T> AsyncReadWrite for T where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
297
298/// Boxed async stream (`AsyncRead` + `AsyncWrite` + Unpin + Send) with a Debug impl.
299///
300/// Used for incoming SSL peer connections where the concrete TLS type is erased.
301pub struct BoxedAsyncStream(pub Box<dyn AsyncReadWrite>);
302
303impl std::fmt::Debug for BoxedAsyncStream {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        f.write_str("BoxedAsyncStream(..)")
306    }
307}
308
309/// Commands sent from a `TorrentHandle` to the `TorrentActor`.
310///
311/// `pub` at the M244b engine split: session-core (`irontide-session`) sends a
312/// subset of these (`ForceResume` / `SetSeedRatioLimit` / `UpdateSettings`) across
313/// the crate boundary, re-exported there as `crate::types::TorrentCommand`.
314#[derive(Debug)]
315#[allow(dead_code)] // most variants are consumed only within the engine crate
316pub enum TorrentCommand {
317    AddPeers {
318        peers: Vec<SocketAddr>,
319        source: crate::peer_state::PeerSource,
320    },
321    Stats {
322        reply: oneshot::Sender<TorrentStats>,
323    },
324    Pause,
325    Queue,
326    Resume,
327    /// Resume the torrent bypassing queue limits (force-start).
328    ForceResume,
329    Shutdown,
330    /// M170: update the category label recorded on this torrent. `None`
331    /// clears the label (uncategorised).
332    SetCategory {
333        category: Option<String>,
334        reply: oneshot::Sender<()>,
335    },
336    /// M171: replace the torrent's tag set wholesale (qBt-compat).
337    ///
338    /// Mirrors qBt's `addTags` / `removeTags` wire behaviour at the API
339    /// layer — always a wholesale replacement at the engine layer.
340    SetTags {
341        tags: Vec<String>,
342        reply: oneshot::Sender<()>,
343    },
344    /// M171 Lane B: snapshot the list of configured web seed URLs
345    /// (BEP 19 `url-list` + BEP 17 `httpseeds`).
346    ///
347    /// Returns an empty vec when metadata hasn't resolved yet.
348    GetWebSeeds {
349        reply: oneshot::Sender<Vec<String>>,
350    },
351    /// M171 Lane B: snapshot the per-piece state array as qBt codes.
352    ///
353    /// Each element is one of {0: not downloaded, 1: downloading,
354    /// 2: downloaded + checked}. Returns an empty vec when metadata
355    /// hasn't resolved yet (piece count unknown).
356    GetPieceStates {
357        reply: oneshot::Sender<Vec<u8>>,
358    },
359    /// M171 Lane B: return a paginated slice of per-piece hashes.
360    ///
361    /// M245 L3: the reply carries the RAW hash bytes for the requested window
362    /// (v1 / hybrid: 20-byte SHA-1; v2-only: 32-byte SHA-256) — one `Vec<u8>`
363    /// per piece. The `hex::encode` is done by the caller
364    /// ([`TorrentHandle::get_piece_hashes`]) OFF the recv loop, so the actor no
365    /// longer encodes (and discards) every hash on its hot path. Returns an
366    /// empty vec when metadata hasn't resolved yet, or when `offset` is past the
367    /// end of the hash list.
368    GetPieceHashes {
369        offset: u32,
370        limit: u32,
371        reply: oneshot::Sender<Vec<Vec<u8>>>,
372    },
373    SaveResumeData {
374        reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
375    },
376    /// M245 F1 — atomically take resume data IFF the torrent is dirty,
377    /// clearing `need_save_resume` in the SAME actor turn (no `.await`
378    /// between the read and the clear). Replaces the racy `SaveResumeData` +
379    /// `ClearSaveResumeFlag` two-step, where a `need_save_resume` set between
380    /// the build and the clear was silently lost. `Ok(None)` = not dirty.
381    TakeResumeIfDirty {
382        reply: oneshot::Sender<crate::Result<Option<irontide_core::FastResumeData>>>,
383    },
384    SetFilePriority {
385        index: usize,
386        priority: irontide_core::FilePriority,
387        reply: oneshot::Sender<crate::Result<()>>,
388    },
389    FilePriorities {
390        reply: oneshot::Sender<Vec<irontide_core::FilePriority>>,
391    },
392    ForceReannounce,
393    TrackerList {
394        reply: oneshot::Sender<Vec<crate::tracker_manager::TrackerInfo>>,
395    },
396    Scrape {
397        reply: oneshot::Sender<Option<(String, irontide_tracker::ScrapeInfo)>>,
398    },
399    /// Incoming peer routed from the session-level accept loop (TCP or uTP).
400    IncomingPeer {
401        stream: crate::transport::BoxedStream,
402        addr: SocketAddr,
403    },
404    /// Open a streaming reader for a file within the torrent.
405    OpenFile {
406        file_index: usize,
407        reply: oneshot::Sender<crate::Result<crate::streaming::FileStreamHandle>>,
408    },
409    /// Update the external IP for BEP 40 peer priority calculation.
410    UpdateExternalIp {
411        ip: std::net::IpAddr,
412    },
413    /// Move torrent data files to a new directory.
414    MoveStorage {
415        new_path: PathBuf,
416        reply: oneshot::Sender<crate::Result<()>>,
417    },
418    /// Incoming SSL peer routed from the session-level SSL listener (M42).
419    ///
420    /// The TLS handshake has already been completed by the session actor.
421    SpawnSslPeer {
422        addr: SocketAddr,
423        stream: BoxedAsyncStream,
424    },
425    /// Set the per-torrent download rate limit (bytes/sec, 0 = unlimited).
426    SetDownloadLimit {
427        bytes_per_sec: u64,
428        reply: oneshot::Sender<()>,
429    },
430    /// Set the per-torrent upload rate limit (bytes/sec, 0 = unlimited).
431    SetUploadLimit {
432        bytes_per_sec: u64,
433        reply: oneshot::Sender<()>,
434    },
435    /// Get the current per-torrent download rate limit (bytes/sec, 0 = unlimited).
436    DownloadLimit {
437        reply: oneshot::Sender<u64>,
438    },
439    /// Get the current per-torrent upload rate limit (bytes/sec, 0 = unlimited).
440    UploadLimit {
441        reply: oneshot::Sender<u64>,
442    },
443    /// Enable or disable sequential (in-order) piece downloading.
444    SetSequentialDownload {
445        enabled: bool,
446        reply: oneshot::Sender<()>,
447    },
448    /// Query whether sequential downloading is enabled.
449    IsSequentialDownload {
450        reply: oneshot::Sender<bool>,
451    },
452    /// M253/ER2: enable or disable first/last-pieces-first ordering.
453    SetPrioritizeFirstLastPieces {
454        enabled: bool,
455        reply: oneshot::Sender<()>,
456    },
457    /// M253/ER2: query whether first/last-pieces-first ordering is enabled.
458    IsPrioritizeFirstLastPieces {
459        reply: oneshot::Sender<bool>,
460    },
461    /// Enable or disable BEP 16 super seeding mode.
462    SetSuperSeeding {
463        enabled: bool,
464        reply: oneshot::Sender<()>,
465    },
466    /// Query whether super seeding mode is enabled.
467    IsSuperSeeding {
468        reply: oneshot::Sender<bool>,
469    },
470    /// Enable or disable user-requested seed-only mode (M159).
471    ///
472    /// When enabled, the torrent stops scheduling new block requests and
473    /// cancels all in-flight requests, but continues to serve uploads to
474    /// interested peers. Mirrors libtorrent's `seed_mode` flag.
475    SetSeedMode {
476        enabled: bool,
477        reply: oneshot::Sender<()>,
478    },
479    /// Override the per-torrent seed ratio limit (`None` = use session default).
480    SetSeedRatioLimit {
481        limit: Option<f64>,
482        reply: oneshot::Sender<()>,
483    },
484    /// Add a new tracker URL (fire-and-forget at torrent level).
485    AddTracker {
486        url: String,
487    },
488    /// Replace all tracker URLs with a new set.
489    ReplaceTrackers {
490        urls: Vec<String>,
491        reply: oneshot::Sender<()>,
492    },
493    /// Trigger a full piece verification (force recheck).
494    ForceRecheck {
495        reply: oneshot::Sender<crate::Result<()>>,
496    },
497    /// Rename a file within the torrent on disk.
498    RenameFile {
499        file_index: usize,
500        new_name: String,
501        reply: oneshot::Sender<crate::Result<()>>,
502    },
503    /// Set the per-torrent maximum number of connections (0 = use global default).
504    SetMaxConnections {
505        limit: usize,
506        reply: oneshot::Sender<()>,
507    },
508    /// Get the current per-torrent maximum connection limit.
509    MaxConnections {
510        reply: oneshot::Sender<usize>,
511    },
512    /// Set the per-torrent maximum number of unchoke slots (upload slots).
513    SetMaxUploads {
514        limit: usize,
515        reply: oneshot::Sender<()>,
516    },
517    /// Get the current per-torrent maximum unchoke slots (upload slots).
518    MaxUploads {
519        reply: oneshot::Sender<usize>,
520    },
521    /// Get per-peer details for all connected peers.
522    GetPeerInfo {
523        reply: oneshot::Sender<Vec<PeerInfo>>,
524    },
525    /// Get in-flight piece download status (the download queue).
526    GetDownloadQueue {
527        reply: oneshot::Sender<Vec<PartialPieceInfo>>,
528    },
529    /// Check whether a specific piece has been downloaded.
530    HavePiece {
531        index: u32,
532        reply: oneshot::Sender<bool>,
533    },
534    /// Get per-piece availability counts from connected peers.
535    PieceAvailability {
536        reply: oneshot::Sender<Vec<u32>>,
537    },
538    /// Get per-file bytes-downloaded progress.
539    FileProgress {
540        reply: oneshot::Sender<Vec<u64>>,
541    },
542    /// Get the torrent's identity hashes (v1 and/or v2).
543    InfoHashes {
544        reply: oneshot::Sender<irontide_core::InfoHashes>,
545    },
546    /// Get the full v1 metainfo (None for magnet links before metadata received).
547    TorrentFile {
548        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
549    },
550    /// Get the full v2 metainfo (None if not a v2/hybrid torrent or before metadata received).
551    TorrentFileV2 {
552        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV2>>,
553    },
554    /// Force an immediate DHT announce (fire-and-forget at torrent level).
555    ForceDhtAnnounce,
556    /// Read all data for a specific piece from disk.
557    ReadPiece {
558        index: u32,
559        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
560    },
561    /// Flush the disk write cache for this torrent.
562    FlushCache {
563        reply: oneshot::Sender<crate::Result<()>>,
564    },
565    /// Clear the error state and resume if the torrent was paused due to error.
566    ClearError,
567    /// Get per-file open/mode status based on torrent state.
568    FileStatus {
569        reply: oneshot::Sender<Vec<crate::types::FileStatus>>,
570    },
571    /// Read the current torrent flags as a bitflag set.
572    Flags {
573        reply: oneshot::Sender<TorrentFlags>,
574    },
575    /// Set (enable) the specified torrent flags.
576    SetFlags {
577        flags: TorrentFlags,
578        reply: oneshot::Sender<()>,
579    },
580    /// Unset (disable) the specified torrent flags.
581    UnsetFlags {
582        flags: TorrentFlags,
583        reply: oneshot::Sender<()>,
584    },
585    /// Immediately initiate a peer connection to the given address.
586    ConnectPeer {
587        addr: SocketAddr,
588    },
589    /// Clear the `need_save_resume` dirty flag after a successful file save (M161).
590    ClearSaveResumeFlag,
591    /// M245 F1 — re-arm `need_save_resume` after a failed resume WRITE.
592    /// [`TakeResumeIfDirty`](Self::TakeResumeIfDirty) clears the flag on
593    /// capture; without this the captured-but-unwritten state would never be
594    /// retried on a later save cycle.
595    MarkResumeDirty,
596    /// Restore a piece bitmap from resume data (M161 Phase 4).
597    ///
598    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
599    /// The handler validates the bitfield length before applying.
600    RestoreResumeBitmap {
601        /// Raw piece bitfield bytes from resume data.
602        pieces: Vec<u8>,
603        /// Reply with `Ok(())` on success or an error if validation fails.
604        reply: oneshot::Sender<crate::Result<()>>,
605    },
606    /// M178: Restore the per-URL web-seed stats map from resume data.
607    ///
608    /// Used by the post-add resume-restore path so that downloaded-byte
609    /// counters and last-error / consecutive-failure state survive app
610    /// restart (Tension-1 fast-resume persistence).
611    RestoreWebSeedStats {
612        /// Map of URL → stats from `FastResumeData::web_seed_stats`.
613        stats: HashMap<String, irontide_core::WebSeedStats>,
614        /// Reply with `Ok(())` on success.
615        reply: oneshot::Sender<crate::Result<()>>,
616    },
617    /// M178 (Lane B3 / TODO-2): cumulative `(pex, lsd)` unique-peer counts
618    /// for the GUI Trackers tab + qBt v2 trackers pseudo-tracker rows.
619    GetPeerSourceCounts {
620        /// Reply with `(pex_peer_count, lsd_peer_count)`.
621        reply: oneshot::Sender<(usize, usize)>,
622    },
623    /// Per-peer cumulative unchoke duration over the torrent's lifetime.
624    /// Keyed by `SocketAddr`; merges live `PeerState` accumulators with
625    /// the durable per-torrent map so reconnects preserve history.
626    /// Used by libtorrent-mirror perf scenarios that gate on
627    /// optimistic-unchoke fairness.
628    QueryUnchokeDurations {
629        /// Reply with one entry per peer ever unchoked by us.
630        reply: oneshot::Sender<HashMap<SocketAddr, std::time::Duration>>,
631    },
632    /// M178 (Lane C): snapshot of per-URL web-seed stats for the qBt v2
633    /// `/api/v2/torrents/webseeds` endpoint and the GUI HTTP Sources tab.
634    GetWebSeedStats {
635        /// Reply with one entry per URL with active stats.
636        reply: oneshot::Sender<Vec<irontide_core::WebSeedStats>>,
637    },
638    /// M147: Pre-resolved metadata from the background `MetadataResolver`.
639    ///
640    /// Sent by `SessionActor::spawn_metadata_resolver()` when the background
641    /// resolver successfully obtains torrent metadata before the `TorrentActor`'s
642    /// own `FetchingMetadata` phase completes. This is a race: first to resolve
643    /// wins; the other path's result is silently discarded.
644    PreResolvedMetadata {
645        /// Raw bencoded info dictionary bytes.
646        info_bytes: Vec<u8>,
647        /// Peers that were successfully connected during metadata resolution
648        /// (for pre-seeding the peer pipeline).
649        peers: Vec<SocketAddr>,
650    },
651    /// v0.173.1: single source of truth for torrent metadata.
652    ///
653    /// Returns `Some(meta.clone())` if the actor has assembled metadata (via
654    /// its own `ut_metadata` fetch or a `PreResolvedMetadata` push), else
655    /// `None`. Replaces `SessionActor.TorrentEntry.meta` as the authoritative
656    /// source — see class-A archaeology in the v0.173.1 plan file at
657    /// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`.
658    GetMeta {
659        /// Reply with `Some(meta)` when available, `None` for a magnet that
660        /// hasn't resolved metadata yet.
661        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
662    },
663    /// **TEST-ONLY (v0.173.2).** Synchronously inject a fully-assembled info-dict
664    /// payload via the same internal handler as the M147 `PreResolvedMetadata`
665    /// path, but with backpressure + completion-ack so tests can rely on the
666    /// metadata being processed when the future resolves. The M147 fast-path
667    /// uses `try_send` and is fire-and-forget by design (resolver shouldn't
668    /// block); this variant is the synchronous-test counterpart.
669    #[cfg(feature = "test-util")]
670    TestInjectMetadata {
671        /// Raw bencoded info dictionary bytes.
672        info_bytes: Vec<u8>,
673        /// Completion ack — fired after `handle_pre_resolved_metadata` returns.
674        reply: oneshot::Sender<()>,
675    },
676    /// v0.187.1: broadcast changed session-level settings to a running torrent.
677    ///
678    /// Patches `self.config` fields so that settings changes made via
679    /// Preferences → Apply take effect on existing torrents, not just
680    /// newly-added ones.
681    ///
682    /// Boxed since M255: the delta is ~60 `Option` fields and rarely sent —
683    /// boxing keeps every `TorrentCommand` channel slot small
684    /// (`clippy::large_enum_variant` tripped when the delta grew).
685    UpdateSettings(Box<SettingsDelta>),
686}