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}