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