Skip to main content

irontide_session/
tracker_manager.rs

1//! Per-torrent tracker announce lifecycle management.
2//!
3//! Parses tracker URLs from torrent metadata, manages announce intervals,
4//! and handles exponential backoff on failure.
5
6use std::net::SocketAddr;
7use std::time::{Duration, Instant};
8
9use serde::Serialize;
10use tokio::sync::mpsc;
11use tracing::{debug, warn};
12
13use irontide_core::{Id20, InfoHashes, TorrentMetaV1};
14use irontide_tracker::{AnnounceEvent, AnnounceRequest, HttpTracker, UdpTracker};
15
16/// Maximum backoff duration for failed trackers.
17const MAX_BACKOFF: Duration = Duration::from_mins(30); // 30 minutes
18
19/// Initial backoff duration after a tracker failure.
20const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
21
22/// Default re-announce interval if tracker doesn't specify one.
23const DEFAULT_INTERVAL: Duration = Duration::from_mins(30); // 30 minutes
24
25/// Protocol type for a tracker URL.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum TrackerProtocol {
28    Http,
29    Udp,
30}
31
32/// State of a single tracker.
33#[derive(Debug, Clone)]
34enum TrackerState {
35    /// Ready for announce.
36    NeedsAnnounce,
37    /// Successfully announced, waiting for re-announce.
38    Active,
39    /// Failed, backing off.
40    Failed { _error: String },
41}
42
43/// A single tracker entry with its state.
44#[derive(Debug, Clone)]
45struct TrackerEntry {
46    url: String,
47    tier: usize,
48    protocol: TrackerProtocol,
49    state: TrackerState,
50    tracker_id: Option<String>,
51    next_announce: Instant,
52    interval: Duration,
53    backoff: Duration,
54    scrape_info: Option<irontide_tracker::ScrapeInfo>,
55    consecutive_failures: u32,
56}
57
58/// Public tracker status (simplified view of internal `TrackerState`).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60pub enum TrackerStatus {
61    /// Tracker has not been contacted yet.
62    NotContacted,
63    /// Last announce succeeded.
64    Working,
65    /// Last announce failed.
66    Error,
67}
68
69/// Public info about a single tracker.
70#[derive(Debug, Clone, Serialize)]
71pub struct TrackerInfo {
72    /// Tracker announce URL.
73    pub url: String,
74    /// Tier index (lower = higher priority).
75    pub tier: usize,
76    /// Current status of this tracker.
77    pub status: TrackerStatus,
78    /// Number of seeders reported by the tracker (from scrape).
79    pub seeders: Option<u32>,
80    /// Number of leechers reported by the tracker (from scrape).
81    pub leechers: Option<u32>,
82    /// Total completed downloads reported by the tracker (from scrape).
83    pub downloaded: Option<u32>,
84    /// Seconds until the next scheduled announce.
85    pub next_announce_secs: u64,
86    /// Number of consecutive announce failures.
87    pub consecutive_failures: u32,
88}
89
90/// Per-tracker announce outcome (success with peer count, or error message).
91#[derive(Debug, Clone)]
92pub(crate) struct TrackerOutcome {
93    pub url: String,
94    pub result: Result<usize, String>,
95}
96
97/// Result of announcing to all trackers: aggregated peers + per-tracker outcomes.
98#[derive(Debug, Clone)]
99pub(crate) struct AnnounceResult {
100    pub peers: Vec<SocketAddr>,
101    pub outcomes: Vec<TrackerOutcome>,
102}
103
104/// M143: Single tracker's announce result, streamed back to the actor.
105///
106/// Each batch contains the result from one tracker announce — either a list of
107/// peer addresses on success, or an error. The `tracker_idx` field identifies
108/// which tracker entry to update.
109#[derive(Debug)]
110pub(crate) struct TrackerPeerBatch {
111    /// Index into `TrackerManager::trackers` for state updates.
112    pub tracker_idx: usize,
113    /// Tracker announce URL (for alert reporting).
114    pub url: String,
115    /// Result: peers + interval + `tracker_id` + seeders + leechers, or error.
116    pub result: Result<AnnounceOk, irontide_tracker::Error>,
117}
118
119/// Successful announce response from a single tracker.
120pub(crate) type AnnounceOk = (
121    Vec<SocketAddr>,
122    u32,
123    Option<String>,
124    Option<u32>,
125    Option<u32>,
126);
127
128/// Per-torrent tracker manager.
129///
130/// Handles the announce lifecycle for all trackers associated with a torrent:
131/// parsing URLs from metadata, scheduling announces, and managing backoff.
132pub(crate) struct TrackerManager {
133    trackers: Vec<TrackerEntry>,
134    info_hash: Id20,
135    info_hashes: InfoHashes,
136    peer_id: Id20,
137    port: u16,
138    http_client: HttpTracker,
139    udp_client: UdpTracker,
140    anonymous_mode: bool,
141    dscp: u8,
142    /// I2P destination Base64 for BEP 7 tracker announces.
143    i2p_destination: Option<String>,
144}
145
146impl TrackerManager {
147    /// Create a `TrackerManager` from torrent metadata (unfiltered, for tests only).
148    ///
149    /// Parses `announce` and `announce_list` (BEP 12) fields, deduplicates URLs,
150    /// and classifies each as HTTP or UDP.
151    #[cfg(test)]
152    pub fn from_torrent(meta: &TorrentMetaV1, peer_id: Id20, port: u16) -> Self {
153        let mut trackers = Vec::new();
154        let mut seen_urls = std::collections::HashSet::new();
155
156        // BEP 12: announce_list takes priority if present
157        if let Some(ref tiers) = meta.announce_list {
158            for (tier_idx, tier) in tiers.iter().enumerate() {
159                for url in tier {
160                    let url = url.trim().to_string();
161                    if url.is_empty() || !seen_urls.insert(url.clone()) {
162                        continue;
163                    }
164                    if let Some(protocol) = classify_url(&url) {
165                        trackers.push(TrackerEntry {
166                            url,
167                            tier: tier_idx,
168                            protocol,
169                            state: TrackerState::NeedsAnnounce,
170                            tracker_id: None,
171                            next_announce: Instant::now(),
172                            interval: DEFAULT_INTERVAL,
173                            backoff: Duration::ZERO,
174                            scrape_info: None,
175                            consecutive_failures: 0,
176                        });
177                    }
178                }
179            }
180        }
181
182        // Fallback: single announce URL (only if not already in announce_list)
183        if let Some(ref url) = meta.announce {
184            let url = url.trim().to_string();
185            if !url.is_empty()
186                && seen_urls.insert(url.clone())
187                && let Some(protocol) = classify_url(&url)
188            {
189                trackers.push(TrackerEntry {
190                    url,
191                    tier: if trackers.is_empty() {
192                        0
193                    } else {
194                        trackers.last().unwrap().tier + 1
195                    },
196                    protocol,
197                    state: TrackerState::NeedsAnnounce,
198                    tracker_id: None,
199                    next_announce: Instant::now(),
200                    interval: DEFAULT_INTERVAL,
201                    backoff: Duration::ZERO,
202                    scrape_info: None,
203                    consecutive_failures: 0,
204                });
205            }
206        }
207
208        Self {
209            trackers,
210            info_hash: meta.info_hash,
211            info_hashes: InfoHashes::v1_only(meta.info_hash),
212            peer_id,
213            port,
214            http_client: HttpTracker::new(),
215            udp_client: UdpTracker::new(),
216            anonymous_mode: false,
217            dscp: 0,
218            i2p_destination: None,
219        }
220    }
221
222    /// Create a `TrackerManager` from torrent metadata with URL security filtering.
223    ///
224    /// Same as [`from_torrent`](Self::from_torrent), but each URL is validated
225    /// through [`validate_tracker_url`](crate::url_guard::validate_tracker_url).
226    /// URLs that fail validation are logged at warn level and skipped.
227    #[allow(dead_code)] // Wired in during Task 3 (TorrentActor integration).
228    pub fn from_torrent_filtered(
229        meta: &TorrentMetaV1,
230        peer_id: Id20,
231        port: u16,
232        security: crate::url_guard::UrlSecurityConfig,
233        dscp: u8,
234        anonymous_mode: bool,
235    ) -> Self {
236        let mut trackers = Vec::new();
237        let mut seen_urls = std::collections::HashSet::new();
238
239        // BEP 12: announce_list takes priority if present
240        if let Some(ref tiers) = meta.announce_list {
241            for (tier_idx, tier) in tiers.iter().enumerate() {
242                for url in tier {
243                    let url = url.trim().to_string();
244                    if url.is_empty() || !seen_urls.insert(url.clone()) {
245                        continue;
246                    }
247                    if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
248                        warn!(%url, %e, "tracker URL rejected by security policy");
249                        continue;
250                    }
251                    if let Some(protocol) = classify_url(&url) {
252                        trackers.push(TrackerEntry {
253                            url,
254                            tier: tier_idx,
255                            protocol,
256                            state: TrackerState::NeedsAnnounce,
257                            tracker_id: None,
258                            next_announce: Instant::now(),
259                            interval: DEFAULT_INTERVAL,
260                            backoff: Duration::ZERO,
261                            scrape_info: None,
262                            consecutive_failures: 0,
263                        });
264                    }
265                }
266            }
267        }
268
269        // Fallback: single announce URL (only if not already in announce_list)
270        if let Some(ref url) = meta.announce {
271            let url = url.trim().to_string();
272            if !url.is_empty() && seen_urls.insert(url.clone()) {
273                if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
274                    warn!(%url, %e, "tracker URL rejected by security policy");
275                } else if let Some(protocol) = classify_url(&url) {
276                    trackers.push(TrackerEntry {
277                        url,
278                        tier: if trackers.is_empty() {
279                            0
280                        } else {
281                            trackers.last().unwrap().tier + 1
282                        },
283                        protocol,
284                        state: TrackerState::NeedsAnnounce,
285                        tracker_id: None,
286                        next_announce: Instant::now(),
287                        interval: DEFAULT_INTERVAL,
288                        backoff: Duration::ZERO,
289                        scrape_info: None,
290                        consecutive_failures: 0,
291                    });
292                }
293            }
294        }
295
296        Self {
297            trackers,
298            info_hash: meta.info_hash,
299            info_hashes: InfoHashes::v1_only(meta.info_hash),
300            peer_id,
301            port,
302            http_client: if anonymous_mode {
303                HttpTracker::with_anonymous()
304            } else {
305                HttpTracker::new()
306            },
307            udp_client: UdpTracker::new().with_dscp(dscp),
308            anonymous_mode,
309            dscp,
310            i2p_destination: None,
311        }
312    }
313
314    /// Create an empty `TrackerManager` (for magnet links before metadata arrives).
315    pub fn empty(
316        info_hash: Id20,
317        peer_id: Id20,
318        port: u16,
319        dscp: u8,
320        anonymous_mode: bool,
321    ) -> Self {
322        Self {
323            trackers: Vec::new(),
324            info_hash,
325            info_hashes: InfoHashes::v1_only(info_hash),
326            peer_id,
327            port,
328            http_client: if anonymous_mode {
329                HttpTracker::with_anonymous()
330            } else {
331                HttpTracker::new()
332            },
333            udp_client: UdpTracker::new().with_dscp(dscp),
334            anonymous_mode,
335            dscp,
336            i2p_destination: None,
337        }
338    }
339
340    /// Populate trackers from metadata once it's been fetched (unfiltered, for tests only).
341    #[cfg(test)]
342    pub fn set_metadata(&mut self, meta: &TorrentMetaV1) {
343        let fresh = Self::from_torrent(meta, self.peer_id, self.port);
344        self.trackers = fresh.trackers;
345    }
346
347    /// Populate trackers from metadata with URL security filtering (magnet link flow).
348    pub fn set_metadata_filtered(
349        &mut self,
350        meta: &TorrentMetaV1,
351        security: crate::url_guard::UrlSecurityConfig,
352    ) {
353        let fresh = Self::from_torrent_filtered(
354            meta,
355            self.peer_id,
356            self.port,
357            security,
358            self.dscp,
359            self.anonymous_mode,
360        );
361        self.trackers = fresh.trackers;
362    }
363
364    /// Set the full info hashes for dual-swarm support (hybrid torrents).
365    pub fn set_info_hashes(&mut self, info_hashes: InfoHashes) {
366        self.info_hashes = info_hashes;
367    }
368
369    /// Set the I2P destination Base64 string for BEP 7 tracker announces.
370    pub fn set_i2p_destination(&mut self, dest: Option<String>) {
371        self.i2p_destination = dest;
372    }
373
374    /// Number of configured trackers.
375    #[cfg(test)]
376    pub fn tracker_count(&self) -> usize {
377        self.trackers.len()
378    }
379
380    /// Duration until the next tracker needs an announce.
381    ///
382    /// Returns `None` if there are no trackers.
383    pub fn next_announce_in(&self) -> Option<Duration> {
384        self.trackers
385            .iter()
386            .map(|t| t.next_announce.saturating_duration_since(Instant::now()))
387            .min()
388    }
389
390    /// Announce to all trackers that are due.
391    ///
392    /// For hybrid torrents, announces both v1 and v2 info hashes separately
393    /// to reach peers in both swarms. Returns all discovered peer addresses (deduplicated).
394    pub async fn announce(
395        &mut self,
396        event: AnnounceEvent,
397        uploaded: u64,
398        downloaded: u64,
399        left: u64,
400    ) -> AnnounceResult {
401        let mut all_peers = Vec::new();
402        let mut seen_peers = std::collections::HashSet::new();
403        let mut all_outcomes = Vec::new();
404
405        // Always announce with the primary (v1) info hash
406        let result = self
407            .announce_with_hash(self.info_hash, event, uploaded, downloaded, left)
408            .await;
409        for peer in result.peers {
410            if seen_peers.insert(peer) {
411                all_peers.push(peer);
412            }
413        }
414        all_outcomes.extend(result.outcomes);
415
416        // Dual-swarm: also announce with v2 hash (truncated) if hybrid
417        if self.info_hashes.is_hybrid()
418            && let Some(v2) = self.info_hashes.v2
419        {
420            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
421            // Only announce the v2 hash if it differs from the v1 hash
422            if v2_as_v1 != self.info_hash {
423                let result = self
424                    .announce_with_hash(v2_as_v1, event, uploaded, downloaded, left)
425                    .await;
426                for peer in result.peers {
427                    if seen_peers.insert(peer) {
428                        all_peers.push(peer);
429                    }
430                }
431                all_outcomes.extend(result.outcomes);
432            }
433        }
434
435        AnnounceResult {
436            peers: all_peers,
437            outcomes: all_outcomes,
438        }
439    }
440
441    /// Returns the port to announce (0 when anonymous mode is active).
442    fn announce_port(&self) -> u16 {
443        if self.anonymous_mode { 0 } else { self.port }
444    }
445
446    /// Internal: announce with a specific info hash to all due trackers.
447    async fn announce_with_hash(
448        &mut self,
449        hash: Id20,
450        event: AnnounceEvent,
451        uploaded: u64,
452        downloaded: u64,
453        left: u64,
454    ) -> AnnounceResult {
455        let req = AnnounceRequest {
456            info_hash: hash,
457            peer_id: self.peer_id,
458            port: self.announce_port(),
459            uploaded,
460            downloaded,
461            left,
462            event,
463            num_want: None,
464            compact: true,
465            i2p_destination: self.i2p_destination.clone(),
466        };
467        let now = Instant::now();
468
469        // Spawn all eligible tracker announces in parallel
470        let mut join_set =
471            tokio::task::JoinSet::<(usize, Result<AnnounceOk, irontide_tracker::Error>)>::new();
472
473        for (idx, tracker) in self.trackers.iter().enumerate() {
474            // For the secondary (v2) hash, always announce (don't skip based on next_announce,
475            // since the timer tracks the primary hash). For the primary hash, respect the timer.
476            if hash == self.info_hash && tracker.next_announce > now {
477                continue;
478            }
479
480            let http_client = self.http_client.clone();
481            let udp_client = self.udp_client.clone();
482            let url = tracker.url.clone();
483            let protocol = tracker.protocol;
484            let req = req.clone();
485
486            join_set.spawn(async move {
487                let result = match protocol {
488                    TrackerProtocol::Http => Self::announce_http(&http_client, &url, &req).await,
489                    TrackerProtocol::Udp => Self::announce_udp(&udp_client, &url, &req).await,
490                };
491                (idx, result)
492            });
493        }
494
495        // Collect results and update tracker state
496        let mut all_peers = Vec::new();
497        let mut seen_peers = std::collections::HashSet::new();
498        let mut outcomes = Vec::new();
499
500        while let Some(Ok((idx, result))) = join_set.join_next().await {
501            let tracker = &mut self.trackers[idx];
502            match result {
503                Ok((peers, interval, tracker_id, seeders, leechers)) => {
504                    let num_peers = peers.len();
505                    debug!(
506                        url = %tracker.url,
507                        peer_count = num_peers,
508                        interval,
509                        %hash,
510                        "tracker announce success"
511                    );
512                    // Only update tracker state for the primary hash
513                    if hash == self.info_hash {
514                        tracker.state = TrackerState::Active;
515                        tracker.interval = Duration::from_secs(u64::from(interval));
516                        tracker.next_announce = now + tracker.interval;
517                        tracker.backoff = Duration::ZERO;
518                        tracker.consecutive_failures = 0;
519                        if let Some(id) = tracker_id {
520                            tracker.tracker_id = Some(id);
521                        }
522                        if seeders.is_some() || leechers.is_some() {
523                            let prev_downloaded = tracker.scrape_info.map_or(0, |s| s.downloaded);
524                            tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
525                                complete: seeders.unwrap_or(0),
526                                incomplete: leechers.unwrap_or(0),
527                                downloaded: prev_downloaded,
528                            });
529                        }
530                    }
531
532                    for peer in peers {
533                        if seen_peers.insert(peer) {
534                            all_peers.push(peer);
535                        }
536                    }
537                    outcomes.push(TrackerOutcome {
538                        url: tracker.url.clone(),
539                        result: Ok(num_peers),
540                    });
541                }
542                Err(e) => {
543                    let msg = e.to_string();
544                    let retry_floor = match &e {
545                        irontide_tracker::Error::TrackerError {
546                            retry_in: Some(secs),
547                            ..
548                        } => Duration::from_secs(u64::from(*secs)),
549                        _ => Duration::ZERO,
550                    };
551                    warn!(url = %tracker.url, error = %msg, %hash, "tracker announce failed");
552                    // Only update failure state for the primary hash
553                    if hash == self.info_hash {
554                        tracker.state = TrackerState::Failed {
555                            _error: msg.clone(),
556                        };
557                        tracker.consecutive_failures += 1;
558                        tracker.backoff = if tracker.backoff.is_zero() {
559                            INITIAL_BACKOFF
560                        } else {
561                            (tracker.backoff * 2).min(MAX_BACKOFF)
562                        };
563                        tracker.backoff = tracker.backoff.max(retry_floor);
564                        tracker.next_announce = now + tracker.backoff;
565                    }
566                    outcomes.push(TrackerOutcome {
567                        url: tracker.url.clone(),
568                        result: Err(msg),
569                    });
570                }
571            }
572        }
573
574        AnnounceResult {
575            peers: all_peers,
576            outcomes,
577        }
578    }
579
580    /// Convenience: announce with Started event.
581    #[allow(dead_code)]
582    pub async fn announce_started(
583        &mut self,
584        uploaded: u64,
585        downloaded: u64,
586        left: u64,
587    ) -> AnnounceResult {
588        self.announce(AnnounceEvent::Started, uploaded, downloaded, left)
589            .await
590    }
591
592    /// Convenience: announce with Completed event.
593    pub async fn announce_completed(&mut self, uploaded: u64, downloaded: u64) -> AnnounceResult {
594        self.announce(AnnounceEvent::Completed, uploaded, downloaded, 0)
595            .await
596    }
597
598    /// Convenience: announce with Stopped event (best-effort, errors ignored).
599    pub async fn announce_stopped(&mut self, uploaded: u64, downloaded: u64, left: u64) {
600        let _ = self
601            .announce(AnnounceEvent::Stopped, uploaded, downloaded, left)
602            .await;
603    }
604
605    // ---- M143: Non-blocking streaming announce ----
606
607    /// Start a non-blocking announce that streams results through a channel.
608    ///
609    /// Spawns tracker requests as a background tokio task. Each tracker's
610    /// response is sent as a [`TrackerPeerBatch`] through the returned receiver
611    /// as soon as that tracker responds. The actor's `select!` loop is never
612    /// blocked — it processes each batch via [`process_tracker_result`].
613    ///
614    /// For hybrid torrents, both v1 and v2 (truncated) info hashes are
615    /// announced so peers from both swarms are discovered.
616    pub fn start_announce(
617        &mut self,
618        event: AnnounceEvent,
619        uploaded: u64,
620        downloaded: u64,
621        left: u64,
622    ) -> mpsc::Receiver<TrackerPeerBatch> {
623        let (tx, rx) = mpsc::channel(32);
624        let now = Instant::now();
625
626        // Collect due trackers into a vec we can move into the spawned task.
627        let mut tasks: Vec<(usize, String, TrackerProtocol)> = Vec::new();
628        for (idx, tracker) in self.trackers.iter().enumerate() {
629            if tracker.next_announce > now {
630                continue;
631            }
632            tasks.push((idx, tracker.url.clone(), tracker.protocol));
633        }
634
635        // Mark all due trackers as "announcing" — push next_announce far enough
636        // ahead to prevent double-announce before results come back. The actual
637        // interval will be set when `process_tracker_result` handles the response.
638        for &(idx, _, _) in &tasks {
639            self.trackers[idx].next_announce = now + Duration::from_mins(2);
640        }
641
642        // Nothing to do — drop tx immediately so rx returns None.
643        if tasks.is_empty() {
644            return rx;
645        }
646
647        let info_hash = self.info_hash;
648        let info_hashes = self.info_hashes.clone();
649        let peer_id = self.peer_id;
650        let port = self.announce_port();
651        let i2p_dest = self.i2p_destination.clone();
652        let http_client = self.http_client.clone();
653        let udp_client = self.udp_client.clone();
654
655        tokio::spawn(async move {
656            let req = AnnounceRequest {
657                info_hash,
658                peer_id,
659                port,
660                uploaded,
661                downloaded,
662                left,
663                event,
664                num_want: None,
665                compact: true,
666                i2p_destination: i2p_dest.clone(),
667            };
668
669            // Primary (v1) info hash
670            Self::spawn_tracker_announces(&tx, &tasks, &req, &http_client, &udp_client).await;
671
672            // Dual-swarm: also announce with v2 hash (truncated) if hybrid
673            if info_hashes.is_hybrid()
674                && let Some(v2) = info_hashes.v2
675            {
676                let v2_as_v1 = Id20(v2.0[..20].try_into().expect("Id32 always has >= 20 bytes"));
677                if v2_as_v1 != info_hash {
678                    let mut v2_req = req;
679                    v2_req.info_hash = v2_as_v1;
680                    Self::spawn_tracker_announces(&tx, &tasks, &v2_req, &http_client, &udp_client)
681                        .await;
682                }
683            }
684            // tx drops here → rx returns None → actor clears tracker_result_rx
685        });
686
687        rx
688    }
689
690    /// Spawn a `JoinSet` of tracker announces for one info hash and stream
691    /// results through `tx`. This is an internal helper for `start_announce`.
692    async fn spawn_tracker_announces(
693        tx: &mpsc::Sender<TrackerPeerBatch>,
694        tasks: &[(usize, String, TrackerProtocol)],
695        req: &AnnounceRequest,
696        http_client: &HttpTracker,
697        udp_client: &UdpTracker,
698    ) {
699        let mut join_set = tokio::task::JoinSet::new();
700
701        for &(idx, ref url, protocol) in tasks {
702            let http = http_client.clone();
703            let udp = udp_client.clone();
704            let url = url.clone();
705            let req = req.clone();
706
707            join_set.spawn(async move {
708                let result = match protocol {
709                    TrackerProtocol::Http => Self::announce_http(&http, &url, &req).await,
710                    TrackerProtocol::Udp => Self::announce_udp(&udp, &url, &req).await,
711                };
712                TrackerPeerBatch {
713                    tracker_idx: idx,
714                    url,
715                    result,
716                }
717            });
718        }
719
720        while let Some(join_result) = join_set.join_next().await {
721            if let Ok(batch) = join_result {
722                // If the actor dropped its rx (e.g. torrent stopped), bail out.
723                if tx.send(batch).await.is_err() {
724                    break;
725                }
726            }
727        }
728    }
729
730    /// Process a single streaming tracker result.
731    ///
732    /// Updates tracker state (interval, backoff, scrape info) and returns
733    /// the discovered peers along with a `TrackerOutcome` for alert firing.
734    pub fn process_tracker_result(
735        &mut self,
736        batch: TrackerPeerBatch,
737    ) -> (Vec<SocketAddr>, TrackerOutcome) {
738        let Some(tracker) = self.trackers.get_mut(batch.tracker_idx) else {
739            // Tracker was removed while announce was in-flight (e.g. replace_all).
740            return (
741                Vec::new(),
742                TrackerOutcome {
743                    url: batch.url,
744                    result: Err("tracker removed during announce".to_string()),
745                },
746            );
747        };
748
749        match batch.result {
750            Ok((peers, interval, tracker_id, seeders, leechers)) => {
751                let num_peers = peers.len();
752                debug!(
753                    url = %tracker.url,
754                    peer_count = num_peers,
755                    interval,
756                    "streaming tracker announce success"
757                );
758                tracker.state = TrackerState::Active;
759                tracker.interval = Duration::from_secs(u64::from(interval));
760                tracker.next_announce = Instant::now() + tracker.interval;
761                tracker.backoff = Duration::ZERO;
762                tracker.consecutive_failures = 0;
763                if let Some(id) = tracker_id {
764                    tracker.tracker_id = Some(id);
765                }
766                if seeders.is_some() || leechers.is_some() {
767                    let prev_downloaded = tracker.scrape_info.map_or(0, |s| s.downloaded);
768                    tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
769                        complete: seeders.unwrap_or(0),
770                        incomplete: leechers.unwrap_or(0),
771                        downloaded: prev_downloaded,
772                    });
773                }
774
775                let outcome = TrackerOutcome {
776                    url: batch.url,
777                    result: Ok(num_peers),
778                };
779                (peers, outcome)
780            }
781            Err(e) => {
782                let msg = e.to_string();
783                let retry_floor = match &e {
784                    irontide_tracker::Error::TrackerError {
785                        retry_in: Some(secs),
786                        ..
787                    } => Duration::from_secs(u64::from(*secs)),
788                    _ => Duration::ZERO,
789                };
790                warn!(url = %tracker.url, error = %msg, "streaming tracker announce failed");
791                tracker.state = TrackerState::Failed {
792                    _error: msg.clone(),
793                };
794                tracker.consecutive_failures = tracker.consecutive_failures.saturating_add(1);
795                tracker.backoff = if tracker.backoff.is_zero() {
796                    INITIAL_BACKOFF
797                } else {
798                    (tracker.backoff.saturating_mul(2)).min(MAX_BACKOFF)
799                };
800                tracker.backoff = tracker.backoff.max(retry_floor);
801                tracker.next_announce = Instant::now() + tracker.backoff;
802
803                let outcome = TrackerOutcome {
804                    url: batch.url,
805                    result: Err(msg),
806                };
807                (Vec::new(), outcome)
808            }
809        }
810    }
811
812    // ---- Internal announce helpers ----
813
814    async fn announce_http(
815        client: &HttpTracker,
816        url: &str,
817        req: &AnnounceRequest,
818    ) -> Result<AnnounceOk, irontide_tracker::Error> {
819        let resp = client.announce(url, req).await?;
820        Ok((
821            resp.response.peers,
822            resp.response.interval,
823            resp.tracker_id,
824            resp.response.seeders,
825            resp.response.leechers,
826        ))
827    }
828
829    async fn announce_udp(
830        client: &UdpTracker,
831        url: &str,
832        req: &AnnounceRequest,
833    ) -> Result<AnnounceOk, irontide_tracker::Error> {
834        // UDP tracker URLs are like "udp://tracker.example.com:6969/announce"
835        // UdpTracker::announce expects "host:port"
836        let addr = parse_udp_addr(url);
837        let resp = client.announce(&addr, req).await?;
838        Ok((
839            resp.response.peers,
840            resp.response.interval,
841            None,
842            resp.response.seeders,
843            resp.response.leechers,
844        ))
845    }
846
847    // ---- New public methods ----
848
849    /// Get a list of all configured trackers with their status.
850    pub fn tracker_list(&self) -> Vec<TrackerInfo> {
851        self.trackers
852            .iter()
853            .map(|t| {
854                let status = match t.state {
855                    TrackerState::NeedsAnnounce => TrackerStatus::NotContacted,
856                    TrackerState::Active => TrackerStatus::Working,
857                    TrackerState::Failed { .. } => TrackerStatus::Error,
858                };
859                TrackerInfo {
860                    url: t.url.clone(),
861                    tier: t.tier,
862                    status,
863                    seeders: t.scrape_info.map(|s| s.complete),
864                    leechers: t.scrape_info.map(|s| s.incomplete),
865                    downloaded: t.scrape_info.map(|s| s.downloaded),
866                    next_announce_secs: t
867                        .next_announce
868                        .saturating_duration_since(Instant::now())
869                        .as_secs(),
870                    consecutive_failures: t.consecutive_failures,
871                }
872            })
873            .collect()
874    }
875
876    /// Force all trackers to re-announce immediately.
877    pub fn force_reannounce(&mut self) {
878        let now = Instant::now();
879        for tracker in &mut self.trackers {
880            tracker.next_announce = now;
881        }
882    }
883
884    /// Add a new tracker URL (e.g. from `lt_trackers` exchange).
885    ///
886    /// Returns `true` if the URL was added, `false` if empty, unknown protocol, or duplicate.
887    pub fn add_tracker_url(&mut self, url: &str) -> bool {
888        let url = url.trim();
889        if url.is_empty() {
890            return false;
891        }
892        let Some(protocol) = classify_url(url) else {
893            return false;
894        };
895        // Deduplicate
896        if self.trackers.iter().any(|t| t.url == url) {
897            return false;
898        }
899        let new_tier = self.trackers.last().map_or(0, |t| t.tier + 1);
900        self.trackers.push(TrackerEntry {
901            url: url.to_string(),
902            tier: new_tier,
903            protocol,
904            state: TrackerState::NeedsAnnounce,
905            tracker_id: None,
906            next_announce: Instant::now(),
907            interval: DEFAULT_INTERVAL,
908            backoff: Duration::ZERO,
909            scrape_info: None,
910            consecutive_failures: 0,
911        });
912        true
913    }
914
915    /// Replace all trackers with a new set of URLs.
916    ///
917    /// Clears the existing tracker list and adds each URL via `add_tracker_url`,
918    /// which handles validation and deduplication.
919    pub fn replace_all(&mut self, urls: &[String]) {
920        self.trackers.clear();
921        for url in urls {
922            self.add_tracker_url(url);
923        }
924    }
925
926    /// Add a new tracker URL with URL security validation.
927    ///
928    /// Returns `true` if the URL was added, `false` if it failed validation,
929    /// was empty, had an unknown protocol, or was a duplicate.
930    #[allow(dead_code)] // Wired in during Task 3 (TorrentActor integration).
931    pub fn add_tracker_url_validated(
932        &mut self,
933        url: &str,
934        security: crate::url_guard::UrlSecurityConfig,
935    ) -> bool {
936        let url = url.trim();
937        if url.is_empty() {
938            return false;
939        }
940        if let Err(e) = crate::url_guard::validate_tracker_url(url, security) {
941            warn!(%url, %e, "tracker URL rejected by security policy");
942            return false;
943        }
944        self.add_tracker_url(url)
945    }
946
947    /// Scrape trackers to get seeder/leecher counts.
948    ///
949    /// Tries each tracker until one succeeds. Returns `(url, ScrapeInfo)` from first success.
950    pub async fn scrape(&self) -> Option<(String, irontide_tracker::ScrapeInfo)> {
951        for tracker in &self.trackers {
952            let result = match tracker.protocol {
953                TrackerProtocol::Http => self
954                    .http_client
955                    .scrape(&tracker.url, &[self.info_hash])
956                    .await
957                    .ok()
958                    .and_then(|resp| resp.files.get(&self.info_hash).copied()),
959                TrackerProtocol::Udp => {
960                    let addr = parse_udp_addr(&tracker.url);
961                    self.udp_client
962                        .scrape(&addr, &[self.info_hash])
963                        .await
964                        .ok()
965                        .and_then(|resp| resp.results.into_iter().next())
966                }
967            };
968            if let Some(info) = result {
969                return Some((tracker.url.clone(), info));
970            }
971        }
972        None
973    }
974}
975
976/// Classify a tracker URL as HTTP or UDP.
977fn classify_url(url: &str) -> Option<TrackerProtocol> {
978    if url.starts_with("http://") || url.starts_with("https://") {
979        Some(TrackerProtocol::Http)
980    } else if url.starts_with("udp://") {
981        Some(TrackerProtocol::Udp)
982    } else {
983        None // Unknown protocol, skip
984    }
985}
986
987/// Extract "host:port" from a UDP tracker URL.
988///
989/// Input: "<udp://tracker.example.com:6969/announce>"
990/// Output: "tracker.example.com:6969"
991fn parse_udp_addr(url: &str) -> String {
992    let without_scheme = url.strip_prefix("udp://").unwrap_or(url);
993    // Strip path (everything after host:port)
994    match without_scheme.find('/') {
995        Some(idx) => without_scheme[..idx].to_string(),
996        None => without_scheme.to_string(),
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003    use irontide_core::{Id20, InfoHashes};
1004
1005    /// Helper to build a minimal `TorrentMetaV1` with given tracker URLs.
1006    fn torrent_with_trackers(
1007        announce: Option<&str>,
1008        announce_list: Option<Vec<Vec<&str>>>,
1009    ) -> TorrentMetaV1 {
1010        use serde::Serialize;
1011
1012        #[derive(Serialize)]
1013        struct Info<'a> {
1014            length: u64,
1015            name: &'a str,
1016            #[serde(rename = "piece length")]
1017            piece_length: u64,
1018            #[serde(with = "serde_bytes")]
1019            pieces: &'a [u8],
1020        }
1021
1022        #[derive(Serialize)]
1023        struct Torrent<'a> {
1024            #[serde(skip_serializing_if = "Option::is_none")]
1025            announce: Option<&'a str>,
1026            info: Info<'a>,
1027        }
1028
1029        let data = vec![0u8; 16384];
1030        let hash = irontide_core::sha1(&data);
1031        let mut pieces = Vec::new();
1032        pieces.extend_from_slice(hash.as_bytes());
1033
1034        let t = Torrent {
1035            announce,
1036            info: Info {
1037                length: 16384,
1038                name: "test",
1039                piece_length: 16384,
1040                pieces: &pieces,
1041            },
1042        };
1043
1044        let bytes = irontide_bencode::to_bytes(&t).unwrap();
1045        let mut meta = irontide_core::torrent_from_bytes(&bytes).unwrap();
1046        meta.announce_list = announce_list.map(|tiers| {
1047            tiers
1048                .into_iter()
1049                .map(|tier| tier.into_iter().map(String::from).collect())
1050                .collect()
1051        });
1052        if announce.is_some() {
1053            meta.announce = announce.map(String::from);
1054        }
1055        meta
1056    }
1057
1058    fn test_peer_id() -> Id20 {
1059        Id20::from_hex("0102030405060708091011121314151617181920").unwrap()
1060    }
1061
1062    #[test]
1063    fn parse_single_announce_url() {
1064        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1065        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1066        assert_eq!(mgr.tracker_count(), 1);
1067        assert_eq!(mgr.trackers[0].protocol, TrackerProtocol::Http);
1068        assert_eq!(mgr.trackers[0].tier, 0);
1069    }
1070
1071    #[test]
1072    fn parse_announce_list_tiers() {
1073        let meta = torrent_with_trackers(
1074            None,
1075            Some(vec![
1076                vec![
1077                    "http://tier0-a.example.com/announce",
1078                    "http://tier0-b.example.com/announce",
1079                ],
1080                vec!["udp://tier1.example.com:6969/announce"],
1081            ]),
1082        );
1083        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1084        assert_eq!(mgr.tracker_count(), 3);
1085        assert_eq!(mgr.trackers[0].tier, 0);
1086        assert_eq!(mgr.trackers[1].tier, 0);
1087        assert_eq!(mgr.trackers[2].tier, 1);
1088        assert_eq!(mgr.trackers[2].protocol, TrackerProtocol::Udp);
1089    }
1090
1091    #[test]
1092    fn classify_http_url() {
1093        assert_eq!(classify_url("http://t.co/a"), Some(TrackerProtocol::Http));
1094        assert_eq!(classify_url("https://t.co/a"), Some(TrackerProtocol::Http));
1095    }
1096
1097    #[test]
1098    fn classify_udp_url() {
1099        assert_eq!(
1100            classify_url("udp://t.co:6969/a"),
1101            Some(TrackerProtocol::Udp)
1102        );
1103    }
1104
1105    #[test]
1106    fn classify_unknown_url() {
1107        assert_eq!(classify_url("wss://t.co/a"), None);
1108    }
1109
1110    #[test]
1111    fn deduplicate_urls() {
1112        let meta = torrent_with_trackers(
1113            Some("http://tracker.example.com/announce"),
1114            Some(vec![vec!["http://tracker.example.com/announce"]]),
1115        );
1116        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1117        // URL appears in both announce and announce_list — should be deduplicated
1118        assert_eq!(mgr.tracker_count(), 1);
1119    }
1120
1121    #[test]
1122    fn empty_announce_list() {
1123        let meta = torrent_with_trackers(None, None);
1124        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1125        assert_eq!(mgr.tracker_count(), 0);
1126        assert_eq!(mgr.next_announce_in(), None);
1127    }
1128
1129    #[test]
1130    fn next_announce_timing() {
1131        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1132        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1133        // Newly created — should be ready to announce immediately
1134        let next = mgr.next_announce_in().unwrap();
1135        assert!(next <= Duration::from_millis(10));
1136    }
1137
1138    #[test]
1139    fn backoff_on_failure() {
1140        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1141        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1142
1143        // Simulate a failure by directly setting state
1144        mgr.trackers[0].state = TrackerState::Failed {
1145            _error: "connection refused".into(),
1146        };
1147        mgr.trackers[0].backoff = INITIAL_BACKOFF;
1148        mgr.trackers[0].next_announce = Instant::now() + INITIAL_BACKOFF;
1149
1150        let next = mgr.next_announce_in().unwrap();
1151        // Should be approximately INITIAL_BACKOFF (30s), give or take
1152        assert!(next >= Duration::from_secs(29));
1153        assert!(next <= Duration::from_secs(31));
1154    }
1155
1156    #[test]
1157    fn backoff_max_cap() {
1158        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1159        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1160
1161        // Simulate many failures — backoff should cap at MAX_BACKOFF
1162        mgr.trackers[0].backoff = Duration::from_mins(20); // 20 min
1163        // Double would be 40 min, but cap is 30 min
1164        let doubled = (mgr.trackers[0].backoff * 2).min(MAX_BACKOFF);
1165        assert_eq!(doubled, MAX_BACKOFF);
1166    }
1167
1168    #[test]
1169    fn parse_udp_addr_strips_scheme_and_path() {
1170        assert_eq!(
1171            parse_udp_addr("udp://tracker.example.com:6969/announce"),
1172            "tracker.example.com:6969"
1173        );
1174        assert_eq!(parse_udp_addr("udp://example.com:1234"), "example.com:1234");
1175    }
1176
1177    #[test]
1178    fn empty_manager_for_magnet() {
1179        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1180        let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1181        assert_eq!(mgr.tracker_count(), 0);
1182    }
1183
1184    #[test]
1185    fn set_metadata_populates_trackers() {
1186        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1187        let mut mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1188        assert_eq!(mgr.tracker_count(), 0);
1189
1190        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1191        mgr.set_metadata(&meta);
1192        assert_eq!(mgr.tracker_count(), 1);
1193    }
1194
1195    #[test]
1196    fn tracker_list_returns_info() {
1197        let meta = torrent_with_trackers(
1198            None,
1199            Some(vec![
1200                vec!["http://tracker1.example.com/announce"],
1201                vec!["udp://tracker2.example.com:6969/announce"],
1202            ]),
1203        );
1204        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1205        let list = mgr.tracker_list();
1206        assert_eq!(list.len(), 2);
1207        assert_eq!(list[0].status, TrackerStatus::NotContacted);
1208        assert_eq!(list[0].seeders, None);
1209        assert_eq!(list[0].tier, 0);
1210        assert_eq!(list[1].tier, 1);
1211    }
1212
1213    #[test]
1214    fn force_reannounce_resets_timers() {
1215        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1216        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1217        // Push next_announce far into the future
1218        mgr.trackers[0].next_announce = Instant::now() + Duration::from_hours(1);
1219        assert!(mgr.next_announce_in().unwrap() > Duration::from_secs(3500));
1220        mgr.force_reannounce();
1221        let next = mgr.next_announce_in().unwrap();
1222        assert!(next <= Duration::from_millis(10));
1223    }
1224
1225    #[test]
1226    fn add_tracker_url_new() {
1227        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1228        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1229        assert_eq!(mgr.tracker_count(), 1);
1230        let added = mgr.add_tracker_url("http://new-tracker.example.com/announce");
1231        assert!(added);
1232        assert_eq!(mgr.tracker_count(), 2);
1233        assert_eq!(mgr.trackers[1].tier, 1); // new tier
1234    }
1235
1236    #[test]
1237    fn add_tracker_url_duplicate() {
1238        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1239        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1240        let added = mgr.add_tracker_url("http://tracker.example.com/announce");
1241        assert!(!added);
1242        assert_eq!(mgr.tracker_count(), 1);
1243    }
1244
1245    #[test]
1246    fn tracker_manager_stores_info_hashes() {
1247        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1248        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1249        assert!(!mgr.info_hashes.is_hybrid());
1250
1251        let v2 = irontide_core::Id32::from_hex(
1252            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1253        )
1254        .unwrap();
1255        mgr.set_info_hashes(InfoHashes::hybrid(meta.info_hash, v2));
1256        assert!(mgr.info_hashes.is_hybrid());
1257    }
1258
1259    #[test]
1260    fn add_tracker_url_empty() {
1261        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1262        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1263        assert!(!mgr.add_tracker_url(""));
1264        assert!(!mgr.add_tracker_url("   "));
1265        assert_eq!(mgr.tracker_count(), 1);
1266    }
1267
1268    fn ssrf_config() -> crate::url_guard::UrlSecurityConfig {
1269        crate::url_guard::UrlSecurityConfig {
1270            ssrf_mitigation: true,
1271            allow_idna: false,
1272            validate_https_trackers: true,
1273        }
1274    }
1275
1276    #[test]
1277    fn localhost_tracker_announce_path_accepted() {
1278        // A localhost URL with /announce path should be accepted by the filter.
1279        let meta = torrent_with_trackers(Some("http://127.0.0.1:8080/announce"), None);
1280        let cfg = ssrf_config();
1281        let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1282        assert_eq!(mgr.tracker_count(), 1);
1283        assert_eq!(mgr.trackers[0].url, "http://127.0.0.1:8080/announce");
1284    }
1285
1286    #[test]
1287    fn localhost_tracker_bad_path_filtered() {
1288        // A localhost URL with a non-/announce path should be rejected,
1289        // while a global URL should pass.
1290        let meta = torrent_with_trackers(
1291            None,
1292            Some(vec![vec![
1293                "http://127.0.0.1:8080/api/admin",
1294                "http://tracker.example.com/announce",
1295            ]]),
1296        );
1297        let cfg = ssrf_config();
1298        let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1299        assert_eq!(mgr.tracker_count(), 1);
1300        assert_eq!(mgr.trackers[0].url, "http://tracker.example.com/announce");
1301    }
1302
1303    #[test]
1304    fn add_tracker_url_validates() {
1305        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1306        let cfg = ssrf_config();
1307        let mut mgr =
1308            TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1309        assert_eq!(mgr.tracker_count(), 1);
1310
1311        // Valid URL should be added.
1312        assert!(mgr.add_tracker_url_validated("http://other.example.com/announce", cfg));
1313        assert_eq!(mgr.tracker_count(), 2);
1314
1315        // Localhost with bad path should be rejected.
1316        assert!(!mgr.add_tracker_url_validated("http://127.0.0.1:8080/api/admin", cfg));
1317        assert_eq!(mgr.tracker_count(), 2);
1318
1319        // UDP URL should pass (UDP skips SSRF checks).
1320        assert!(mgr.add_tracker_url_validated("udp://tracker.example.com:6969/announce", cfg));
1321        assert_eq!(mgr.tracker_count(), 3);
1322    }
1323
1324    #[test]
1325    fn anonymous_mode_zeroes_announce_port() {
1326        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1327        let cfg = ssrf_config();
1328        let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, true);
1329        assert!(mgr.anonymous_mode);
1330        assert_eq!(mgr.announce_port(), 0);
1331    }
1332
1333    #[test]
1334    fn normal_mode_includes_port() {
1335        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1336        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1337        assert!(!mgr.anonymous_mode);
1338        assert_eq!(mgr.announce_port(), 6881);
1339    }
1340
1341    #[test]
1342    fn empty_manager_with_dscp_and_anonymous() {
1343        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1344        let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0x2E, true);
1345        assert!(mgr.anonymous_mode);
1346        assert_eq!(mgr.dscp, 0x2E);
1347        assert_eq!(mgr.announce_port(), 0);
1348    }
1349
1350    #[test]
1351    fn failure_with_retry_in_floors_backoff() {
1352        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1353        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1354
1355        let batch = TrackerPeerBatch {
1356            tracker_idx: 0,
1357            url: "http://tracker.example.com/announce".into(),
1358            result: Err(irontide_tracker::Error::TrackerError {
1359                message: "rate limited".into(),
1360                retry_in: Some(120),
1361            }),
1362        };
1363
1364        let (_peers, outcome) = mgr.process_tracker_result(batch);
1365        assert!(outcome.result.is_err());
1366        assert!(mgr.trackers[0].backoff >= Duration::from_mins(2));
1367    }
1368
1369    #[test]
1370    fn failure_without_retry_in_uses_exponential() {
1371        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1372        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1373
1374        let batch = TrackerPeerBatch {
1375            tracker_idx: 0,
1376            url: "http://tracker.example.com/announce".into(),
1377            result: Err(irontide_tracker::Error::TrackerError {
1378                message: "connection refused".into(),
1379                retry_in: None,
1380            }),
1381        };
1382
1383        let (_peers, outcome) = mgr.process_tracker_result(batch);
1384        assert!(outcome.result.is_err());
1385        assert_eq!(mgr.trackers[0].backoff, INITIAL_BACKOFF);
1386    }
1387
1388    #[test]
1389    fn success_with_min_interval_floors_reannounce() {
1390        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1391        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1392
1393        // Simulate a success where min_interval (1800) > raw interval (900).
1394        // The flooring happens in http.rs, so the interval arriving here is already 1800.
1395        let batch = TrackerPeerBatch {
1396            tracker_idx: 0,
1397            url: "http://tracker.example.com/announce".into(),
1398            result: Ok((
1399                vec!["192.168.1.1:6881".parse().unwrap()],
1400                1800, // already floored from http.rs
1401                None,
1402                Some(10),
1403                Some(5),
1404            )),
1405        };
1406
1407        let (peers, outcome) = mgr.process_tracker_result(batch);
1408        assert_eq!(peers.len(), 1);
1409        assert!(outcome.result.is_ok());
1410        assert_eq!(mgr.trackers[0].interval, Duration::from_mins(30));
1411    }
1412}