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, PeerInfo, SessionStats, SettingsDelta, TorrentConfig,
18    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    /// Enable or disable BEP 16 super seeding mode.
447    SetSuperSeeding {
448        enabled: bool,
449        reply: oneshot::Sender<()>,
450    },
451    /// Query whether super seeding mode is enabled.
452    IsSuperSeeding {
453        reply: oneshot::Sender<bool>,
454    },
455    /// Enable or disable user-requested seed-only mode (M159).
456    ///
457    /// When enabled, the torrent stops scheduling new block requests and
458    /// cancels all in-flight requests, but continues to serve uploads to
459    /// interested peers. Mirrors libtorrent's `seed_mode` flag.
460    SetSeedMode {
461        enabled: bool,
462        reply: oneshot::Sender<()>,
463    },
464    /// Override the per-torrent seed ratio limit (`None` = use session default).
465    SetSeedRatioLimit {
466        limit: Option<f64>,
467        reply: oneshot::Sender<()>,
468    },
469    /// Add a new tracker URL (fire-and-forget at torrent level).
470    AddTracker {
471        url: String,
472    },
473    /// Replace all tracker URLs with a new set.
474    ReplaceTrackers {
475        urls: Vec<String>,
476        reply: oneshot::Sender<()>,
477    },
478    /// Trigger a full piece verification (force recheck).
479    ForceRecheck {
480        reply: oneshot::Sender<crate::Result<()>>,
481    },
482    /// Rename a file within the torrent on disk.
483    RenameFile {
484        file_index: usize,
485        new_name: String,
486        reply: oneshot::Sender<crate::Result<()>>,
487    },
488    /// Set the per-torrent maximum number of connections (0 = use global default).
489    SetMaxConnections {
490        limit: usize,
491        reply: oneshot::Sender<()>,
492    },
493    /// Get the current per-torrent maximum connection limit.
494    MaxConnections {
495        reply: oneshot::Sender<usize>,
496    },
497    /// Set the per-torrent maximum number of unchoke slots (upload slots).
498    SetMaxUploads {
499        limit: usize,
500        reply: oneshot::Sender<()>,
501    },
502    /// Get the current per-torrent maximum unchoke slots (upload slots).
503    MaxUploads {
504        reply: oneshot::Sender<usize>,
505    },
506    /// Get per-peer details for all connected peers.
507    GetPeerInfo {
508        reply: oneshot::Sender<Vec<PeerInfo>>,
509    },
510    /// Get in-flight piece download status (the download queue).
511    GetDownloadQueue {
512        reply: oneshot::Sender<Vec<PartialPieceInfo>>,
513    },
514    /// Check whether a specific piece has been downloaded.
515    HavePiece {
516        index: u32,
517        reply: oneshot::Sender<bool>,
518    },
519    /// Get per-piece availability counts from connected peers.
520    PieceAvailability {
521        reply: oneshot::Sender<Vec<u32>>,
522    },
523    /// Get per-file bytes-downloaded progress.
524    FileProgress {
525        reply: oneshot::Sender<Vec<u64>>,
526    },
527    /// Get the torrent's identity hashes (v1 and/or v2).
528    InfoHashes {
529        reply: oneshot::Sender<irontide_core::InfoHashes>,
530    },
531    /// Get the full v1 metainfo (None for magnet links before metadata received).
532    TorrentFile {
533        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
534    },
535    /// Get the full v2 metainfo (None if not a v2/hybrid torrent or before metadata received).
536    TorrentFileV2 {
537        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV2>>,
538    },
539    /// Force an immediate DHT announce (fire-and-forget at torrent level).
540    ForceDhtAnnounce,
541    /// Read all data for a specific piece from disk.
542    ReadPiece {
543        index: u32,
544        reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
545    },
546    /// Flush the disk write cache for this torrent.
547    FlushCache {
548        reply: oneshot::Sender<crate::Result<()>>,
549    },
550    /// Clear the error state and resume if the torrent was paused due to error.
551    ClearError,
552    /// Get per-file open/mode status based on torrent state.
553    FileStatus {
554        reply: oneshot::Sender<Vec<crate::types::FileStatus>>,
555    },
556    /// Read the current torrent flags as a bitflag set.
557    Flags {
558        reply: oneshot::Sender<TorrentFlags>,
559    },
560    /// Set (enable) the specified torrent flags.
561    SetFlags {
562        flags: TorrentFlags,
563        reply: oneshot::Sender<()>,
564    },
565    /// Unset (disable) the specified torrent flags.
566    UnsetFlags {
567        flags: TorrentFlags,
568        reply: oneshot::Sender<()>,
569    },
570    /// Immediately initiate a peer connection to the given address.
571    ConnectPeer {
572        addr: SocketAddr,
573    },
574    /// Clear the `need_save_resume` dirty flag after a successful file save (M161).
575    ClearSaveResumeFlag,
576    /// M245 F1 — re-arm `need_save_resume` after a failed resume WRITE.
577    /// [`TakeResumeIfDirty`](Self::TakeResumeIfDirty) clears the flag on
578    /// capture; without this the captured-but-unwritten state would never be
579    /// retried on a later save cycle.
580    MarkResumeDirty,
581    /// Restore a piece bitmap from resume data (M161 Phase 4).
582    ///
583    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
584    /// The handler validates the bitfield length before applying.
585    RestoreResumeBitmap {
586        /// Raw piece bitfield bytes from resume data.
587        pieces: Vec<u8>,
588        /// Reply with `Ok(())` on success or an error if validation fails.
589        reply: oneshot::Sender<crate::Result<()>>,
590    },
591    /// M178: Restore the per-URL web-seed stats map from resume data.
592    ///
593    /// Used by the post-add resume-restore path so that downloaded-byte
594    /// counters and last-error / consecutive-failure state survive app
595    /// restart (Tension-1 fast-resume persistence).
596    RestoreWebSeedStats {
597        /// Map of URL → stats from `FastResumeData::web_seed_stats`.
598        stats: HashMap<String, irontide_core::WebSeedStats>,
599        /// Reply with `Ok(())` on success.
600        reply: oneshot::Sender<crate::Result<()>>,
601    },
602    /// M178 (Lane B3 / TODO-2): cumulative `(pex, lsd)` unique-peer counts
603    /// for the GUI Trackers tab + qBt v2 trackers pseudo-tracker rows.
604    GetPeerSourceCounts {
605        /// Reply with `(pex_peer_count, lsd_peer_count)`.
606        reply: oneshot::Sender<(usize, usize)>,
607    },
608    /// Per-peer cumulative unchoke duration over the torrent's lifetime.
609    /// Keyed by `SocketAddr`; merges live `PeerState` accumulators with
610    /// the durable per-torrent map so reconnects preserve history.
611    /// Used by libtorrent-mirror perf scenarios that gate on
612    /// optimistic-unchoke fairness.
613    QueryUnchokeDurations {
614        /// Reply with one entry per peer ever unchoked by us.
615        reply: oneshot::Sender<HashMap<SocketAddr, std::time::Duration>>,
616    },
617    /// M178 (Lane C): snapshot of per-URL web-seed stats for the qBt v2
618    /// `/api/v2/torrents/webseeds` endpoint and the GUI HTTP Sources tab.
619    GetWebSeedStats {
620        /// Reply with one entry per URL with active stats.
621        reply: oneshot::Sender<Vec<irontide_core::WebSeedStats>>,
622    },
623    /// M147: Pre-resolved metadata from the background `MetadataResolver`.
624    ///
625    /// Sent by `SessionActor::spawn_metadata_resolver()` when the background
626    /// resolver successfully obtains torrent metadata before the `TorrentActor`'s
627    /// own `FetchingMetadata` phase completes. This is a race: first to resolve
628    /// wins; the other path's result is silently discarded.
629    PreResolvedMetadata {
630        /// Raw bencoded info dictionary bytes.
631        info_bytes: Vec<u8>,
632        /// Peers that were successfully connected during metadata resolution
633        /// (for pre-seeding the peer pipeline).
634        peers: Vec<SocketAddr>,
635    },
636    /// v0.173.1: single source of truth for torrent metadata.
637    ///
638    /// Returns `Some(meta.clone())` if the actor has assembled metadata (via
639    /// its own `ut_metadata` fetch or a `PreResolvedMetadata` push), else
640    /// `None`. Replaces `SessionActor.TorrentEntry.meta` as the authoritative
641    /// source — see class-A archaeology in the v0.173.1 plan file at
642    /// `docs/plans/2026-04-22-irontide-v0.173.1-qbt-v2-bug-sweep.md`.
643    GetMeta {
644        /// Reply with `Some(meta)` when available, `None` for a magnet that
645        /// hasn't resolved metadata yet.
646        reply: oneshot::Sender<Option<irontide_core::TorrentMetaV1>>,
647    },
648    /// **TEST-ONLY (v0.173.2).** Synchronously inject a fully-assembled info-dict
649    /// payload via the same internal handler as the M147 `PreResolvedMetadata`
650    /// path, but with backpressure + completion-ack so tests can rely on the
651    /// metadata being processed when the future resolves. The M147 fast-path
652    /// uses `try_send` and is fire-and-forget by design (resolver shouldn't
653    /// block); this variant is the synchronous-test counterpart.
654    #[cfg(feature = "test-util")]
655    TestInjectMetadata {
656        /// Raw bencoded info dictionary bytes.
657        info_bytes: Vec<u8>,
658        /// Completion ack — fired after `handle_pre_resolved_metadata` returns.
659        reply: oneshot::Sender<()>,
660    },
661    /// v0.187.1: broadcast changed session-level settings to a running torrent.
662    ///
663    /// Patches `self.config` fields so that settings changes made via
664    /// Preferences → Apply take effect on existing torrents, not just
665    /// newly-added ones.
666    UpdateSettings(SettingsDelta),
667}