Skip to main content

irontide_session/
choker.rs

1use std::net::SocketAddr;
2
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Algorithm enums
7// ---------------------------------------------------------------------------
8
9/// Choking algorithm used when we are seeding.
10#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum SeedChokingAlgorithm {
13    /// Unchoke peers we upload to fastest.
14    #[default]
15    FastestUpload,
16    /// Round-robin through all interested peers.
17    RoundRobin,
18    /// Prefer leechers over seeds (anti-leech).
19    AntiLeech,
20}
21
22/// Top-level choking algorithm variant.
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ChokingAlgorithm {
26    /// Fixed number of unchoke slots (libtorrent default).
27    #[default]
28    FixedSlots,
29    /// Rate-based unchoking (auto-adjusts slots).
30    RateBased,
31}
32
33// ---------------------------------------------------------------------------
34// PeerInfo
35// ---------------------------------------------------------------------------
36
37/// Information about a peer used by the choking algorithm.
38#[derive(Debug, Clone)]
39pub(crate) struct PeerInfo {
40    pub addr: SocketAddr,
41    /// Bytes/sec they are uploading TO us.
42    pub download_rate: u64,
43    /// Bytes/sec we are uploading TO them.
44    pub upload_rate: u64,
45    /// Peer is interested in our data.
46    pub interested: bool,
47    /// BEP 21: peer declared upload-only status.
48    pub upload_only: bool,
49    /// Whether this peer is a seed (has all pieces).
50    pub is_seed: bool,
51}
52
53// ---------------------------------------------------------------------------
54// ChokeDecision
55// ---------------------------------------------------------------------------
56
57/// Result of a choking decision.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) struct ChokeDecision {
60    /// Peers that should be unchoked.
61    pub to_unchoke: Vec<SocketAddr>,
62    /// Peers that should be choked.
63    pub to_choke: Vec<SocketAddr>,
64}
65
66// ---------------------------------------------------------------------------
67// ChokerStrategy trait
68// ---------------------------------------------------------------------------
69
70/// Trait for pluggable choking strategies.
71pub(crate) trait ChokerStrategy: Send + Sync {
72    /// Given the current peer list, decide who to unchoke/choke.
73    fn decide(
74        &mut self,
75        peers: &[PeerInfo],
76        unchoke_slots: usize,
77        seed_mode: bool,
78    ) -> ChokeDecision;
79
80    /// Rotate the optimistic unchoke peer.
81    fn rotate_optimistic(&mut self, peers: &[PeerInfo]);
82
83    /// Observe current throughput for rate-based slot adjustment.
84    /// No-op by default (fixed-slots strategies ignore this).
85    fn observe_throughput(&mut self, _throughput: u64) {}
86
87    /// Return the dynamically computed slot count, if this strategy manages its own.
88    /// Returns `None` by default (fixed-slots strategies defer to the `Choker`'s `unchoke_slots`).
89    #[allow(dead_code)] // Part of the trait API; called by RateBasedStrategy but not dispatched externally yet.
90    fn dynamic_slots(&self) -> Option<usize> {
91        None
92    }
93}
94
95// ---------------------------------------------------------------------------
96// FixedSlotsStrategy
97// ---------------------------------------------------------------------------
98
99/// Fixed-slots choking strategy.
100///
101/// Leech mode: tit-for-tat (sort by download rate descending).
102/// Seed mode: configurable via [`SeedChokingAlgorithm`].
103pub(crate) struct FixedSlotsStrategy {
104    optimistic_peer: Option<SocketAddr>,
105    seed_algorithm: SeedChokingAlgorithm,
106    round_robin_offset: usize,
107}
108
109impl FixedSlotsStrategy {
110    pub fn new(seed_algorithm: SeedChokingAlgorithm) -> Self {
111        Self {
112            optimistic_peer: None,
113            seed_algorithm,
114            round_robin_offset: 0,
115        }
116    }
117
118    /// Select an optimistic unchoke peer.
119    ///
120    /// If the current optimistic peer is interested and not in the regular set,
121    /// keep it. Otherwise pick the first interested peer not in the regular set.
122    fn select_optimistic(
123        &self,
124        interested: &[&PeerInfo],
125        regular_unchokes: &[SocketAddr],
126    ) -> Option<SocketAddr> {
127        // Keep existing optimistic peer if it qualifies.
128        if let Some(opt) = self.optimistic_peer {
129            let still_interested = interested.iter().any(|p| p.addr == opt && !p.upload_only);
130            let already_regular = regular_unchokes.contains(&opt);
131            if still_interested && !already_regular {
132                return Some(opt);
133            }
134        }
135
136        // Pick first interested peer not already in regular unchokes (exclude upload-only).
137        interested
138            .iter()
139            .find(|p| !regular_unchokes.contains(&p.addr) && !p.upload_only)
140            .map(|p| p.addr)
141    }
142
143    /// Sort interested peers according to the seed-mode algorithm.
144    fn sort_seed_mode(&mut self, interested: &mut [&PeerInfo], unchoke_slots: usize) {
145        match self.seed_algorithm {
146            SeedChokingAlgorithm::FastestUpload => {
147                interested.sort_by(|a, b| b.upload_rate.cmp(&a.upload_rate));
148            }
149            SeedChokingAlgorithm::RoundRobin => {
150                // Sort by addr for a deterministic order, then rotate.
151                interested.sort_by(|a, b| a.addr.cmp(&b.addr));
152                if !interested.is_empty() {
153                    let offset = self.round_robin_offset % interested.len();
154                    interested.rotate_left(offset);
155                    self.round_robin_offset =
156                        self.round_robin_offset.wrapping_add(unchoke_slots) % interested.len();
157                }
158            }
159            SeedChokingAlgorithm::AntiLeech => {
160                // Non-seed peers first (sorted by upload rate desc), then seeds.
161                interested.sort_by(|a, b| match (a.is_seed, b.is_seed) {
162                    (false, true) => std::cmp::Ordering::Less,
163                    (true, false) => std::cmp::Ordering::Greater,
164                    _ => b.upload_rate.cmp(&a.upload_rate),
165                });
166            }
167        }
168    }
169}
170
171impl ChokerStrategy for FixedSlotsStrategy {
172    fn decide(
173        &mut self,
174        peers: &[PeerInfo],
175        unchoke_slots: usize,
176        seed_mode: bool,
177    ) -> ChokeDecision {
178        let all_addrs: Vec<SocketAddr> = peers.iter().map(|p| p.addr).collect();
179
180        // Interested peers sorted by rate descending.
181        let mut interested: Vec<&PeerInfo> = peers.iter().filter(|p| p.interested).collect();
182        if seed_mode {
183            self.sort_seed_mode(&mut interested, unchoke_slots);
184        } else {
185            // Leech mode: unchoke peers that upload to us fastest (tit-for-tat)
186            interested.sort_by(|a, b| b.download_rate.cmp(&a.download_rate));
187        }
188
189        // Regular unchokes: top N.
190        let regular_count = unchoke_slots.min(interested.len());
191        let regular_unchokes: Vec<SocketAddr> =
192            interested[..regular_count].iter().map(|p| p.addr).collect();
193
194        // Optimistic unchoke selection.
195        let optimistic = self.select_optimistic(&interested, &regular_unchokes);
196        self.optimistic_peer = optimistic;
197
198        // Build the final unchoke set.
199        let mut to_unchoke = regular_unchokes;
200        if let Some(opt) = optimistic
201            && !to_unchoke.contains(&opt)
202        {
203            to_unchoke.push(opt);
204        }
205
206        // Everyone not in to_unchoke gets choked.
207        let to_choke: Vec<SocketAddr> = all_addrs
208            .into_iter()
209            .filter(|a| !to_unchoke.contains(a))
210            .collect();
211
212        ChokeDecision {
213            to_unchoke,
214            to_choke,
215        }
216    }
217
218    fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
219        let mut interested: Vec<&PeerInfo> = peers
220            .iter()
221            .filter(|p| p.interested && !p.upload_only)
222            .collect();
223        // Sort ascending by download rate so the first non-optimistic is picked.
224        interested.sort_by(|a, b| a.download_rate.cmp(&b.download_rate));
225
226        self.optimistic_peer = interested
227            .iter()
228            .find(|p| Some(p.addr) != self.optimistic_peer)
229            .map(|p| p.addr);
230    }
231}
232
233// ---------------------------------------------------------------------------
234// RateBasedStrategy
235// ---------------------------------------------------------------------------
236
237/// Rate-based choking strategy that dynamically adjusts unchoke slots
238/// based on observed throughput.
239///
240/// When throughput increases, adds slots to utilize available bandwidth.
241/// When throughput drops significantly (>10%), removes slots to reduce
242/// overhead. Respects configured min/max bounds and upload rate limits.
243pub(crate) struct RateBasedStrategy {
244    /// Underlying fixed-slots strategy for peer ranking and optimistic unchoke.
245    inner: FixedSlotsStrategy,
246    /// Current number of dynamically-adjusted unchoke slots.
247    dynamic_slots: usize,
248    /// Previous throughput observation (bytes/sec).
249    prev_throughput: u64,
250    /// Upload rate limit (bytes/sec). 0 means unlimited.
251    upload_rate_limit: u64,
252    /// Minimum number of unchoke slots.
253    min_slots: usize,
254    /// Maximum number of unchoke slots.
255    max_slots: usize,
256}
257
258impl RateBasedStrategy {
259    pub fn new(
260        seed_algorithm: SeedChokingAlgorithm,
261        upload_rate_limit: u64,
262        min_slots: usize,
263        max_slots: usize,
264    ) -> Self {
265        Self {
266            inner: FixedSlotsStrategy::new(seed_algorithm),
267            dynamic_slots: min_slots.max(2),
268            prev_throughput: 0,
269            upload_rate_limit,
270            min_slots,
271            max_slots,
272        }
273    }
274
275    /// Observe current aggregate throughput and adjust slot count.
276    fn observe_throughput_inner(&mut self, throughput: u64) {
277        // First observation with throughput > 0: just set baseline.
278        if self.prev_throughput == 0 && throughput > 0 {
279            self.prev_throughput = throughput;
280            return;
281        }
282
283        if throughput > self.prev_throughput {
284            // Throughput increased — consider adding a slot.
285            // But not if we're already at capacity (>90% of upload rate limit).
286            let at_capacity =
287                self.upload_rate_limit > 0 && throughput > self.upload_rate_limit * 90 / 100;
288
289            if !at_capacity && self.dynamic_slots < self.max_slots {
290                self.dynamic_slots += 1;
291            }
292        } else if self.prev_throughput > 0 {
293            // Check for >10% drop.
294            let threshold = self.prev_throughput * 90 / 100;
295            if throughput < threshold && self.dynamic_slots > self.min_slots {
296                self.dynamic_slots -= 1;
297            }
298        }
299
300        // Check for headroom: if we have spare capacity, try adding a slot.
301        if self.upload_rate_limit > 0
302            && throughput < self.upload_rate_limit
303            && self.dynamic_slots > 0
304        {
305            let per_slot_avg = throughput / self.dynamic_slots as u64;
306            if per_slot_avg > 0 {
307                let headroom = self.upload_rate_limit - throughput;
308                if headroom > per_slot_avg && self.dynamic_slots < self.max_slots {
309                    self.dynamic_slots += 1;
310                }
311            }
312        }
313
314        self.prev_throughput = throughput;
315    }
316
317    /// Return the current dynamically-adjusted slot count.
318    #[cfg(test)]
319    pub fn current_slots(&self) -> usize {
320        self.dynamic_slots
321    }
322}
323
324impl ChokerStrategy for RateBasedStrategy {
325    fn decide(
326        &mut self,
327        peers: &[PeerInfo],
328        _unchoke_slots: usize,
329        seed_mode: bool,
330    ) -> ChokeDecision {
331        // Ignore external unchoke_slots — use our dynamic count.
332        self.inner.decide(peers, self.dynamic_slots, seed_mode)
333    }
334
335    fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
336        self.inner.rotate_optimistic(peers);
337    }
338
339    fn observe_throughput(&mut self, throughput: u64) {
340        self.observe_throughput_inner(throughput);
341    }
342
343    fn dynamic_slots(&self) -> Option<usize> {
344        Some(self.dynamic_slots)
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Choker (dispatcher)
350// ---------------------------------------------------------------------------
351
352/// Choking algorithm dispatcher.
353///
354/// Delegates to a pluggable [`ChokerStrategy`] implementation. Backward
355/// compatible: `Choker::new(n)` creates a fixed-slots strategy with
356/// `FastestUpload` seed algorithm (same behavior as the original `Choker`).
357pub(crate) struct Choker {
358    strategy: Box<dyn ChokerStrategy>,
359    unchoke_slots: usize,
360    seed_mode: bool,
361    #[allow(dead_code)] // Read via #[cfg(test)] accessor.
362    choking_algorithm: ChokingAlgorithm,
363}
364
365impl Choker {
366    /// Create a choker with the given number of regular unchoke slots.
367    ///
368    /// Uses [`FixedSlotsStrategy`] with [`SeedChokingAlgorithm::FastestUpload`],
369    /// matching the behavior of the original monolithic `Choker`.
370    #[cfg(test)]
371    pub fn new(unchoke_slots: usize) -> Self {
372        Self {
373            strategy: Box::new(FixedSlotsStrategy::new(SeedChokingAlgorithm::FastestUpload)),
374            unchoke_slots,
375            seed_mode: false,
376            choking_algorithm: ChokingAlgorithm::FixedSlots,
377        }
378    }
379
380    /// Create a choker with explicit algorithm configuration.
381    pub fn with_algorithms(
382        unchoke_slots: usize,
383        seed_algorithm: SeedChokingAlgorithm,
384        choking_algorithm: ChokingAlgorithm,
385        upload_rate_limit: u64,
386        min_slots: usize,
387        max_slots: usize,
388    ) -> Self {
389        let strategy: Box<dyn ChokerStrategy> = match choking_algorithm {
390            ChokingAlgorithm::FixedSlots => Box::new(FixedSlotsStrategy::new(seed_algorithm)),
391            ChokingAlgorithm::RateBased => Box::new(RateBasedStrategy::new(
392                seed_algorithm,
393                upload_rate_limit,
394                min_slots,
395                max_slots,
396            )),
397        };
398
399        Self {
400            strategy,
401            unchoke_slots,
402            seed_mode: false,
403            choking_algorithm,
404        }
405    }
406
407    pub fn set_seed_mode(&mut self, seed_mode: bool) {
408        self.seed_mode = seed_mode;
409    }
410
411    /// Update the number of regular unchoke slots (used by auto upload slot tuner).
412    pub fn set_unchoke_slots(&mut self, n: usize) {
413        self.unchoke_slots = n;
414    }
415
416    /// Return the current number of regular unchoke slots.
417    #[allow(dead_code)] // Used by make_stats() in a later task.
418    pub fn unchoke_slots(&self) -> usize {
419        self.unchoke_slots
420    }
421
422    /// Observe current aggregate throughput for rate-based slot adjustment.
423    pub fn observe_throughput(&mut self, throughput: u64) {
424        self.strategy.observe_throughput(throughput);
425    }
426
427    /// Return the choking algorithm variant.
428    #[cfg(test)]
429    pub fn choking_algorithm(&self) -> ChokingAlgorithm {
430        self.choking_algorithm
431    }
432
433    /// Decide which peers to unchoke and choke.
434    pub fn decide(&mut self, peers: &[PeerInfo]) -> ChokeDecision {
435        self.strategy
436            .decide(peers, self.unchoke_slots, self.seed_mode)
437    }
438
439    /// Pick a new optimistic peer from interested peers.
440    pub fn rotate_optimistic(&mut self, peers: &[PeerInfo]) {
441        self.strategy.rotate_optimistic(peers);
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn addr(port: u16) -> SocketAddr {
450        format!("127.0.0.1:{port}").parse().unwrap()
451    }
452
453    fn peer(port: u16, download_rate: u64, interested: bool) -> PeerInfo {
454        PeerInfo {
455            addr: addr(port),
456            download_rate,
457            upload_rate: 0,
458            interested,
459            upload_only: false,
460            is_seed: false,
461        }
462    }
463
464    fn seed_peer(port: u16, upload_rate: u64, interested: bool) -> PeerInfo {
465        PeerInfo {
466            addr: addr(port),
467            download_rate: 0,
468            upload_rate,
469            interested,
470            upload_only: false,
471            is_seed: false,
472        }
473    }
474
475    // -----------------------------------------------------------------------
476    // Existing Choker tests (unchanged logic, updated PeerInfo construction)
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn unchoke_top_n() {
481        let mut choker = Choker::new(4);
482        let peers = vec![
483            peer(6881, 100, true),
484            peer(6882, 500, true),
485            peer(6883, 300, true),
486            peer(6884, 200, true),
487            peer(6885, 400, true),
488            peer(6886, 50, true),
489        ];
490
491        let decision = choker.decide(&peers);
492
493        // Top 4 by rate: 500, 400, 300, 200 (ports 6882, 6885, 6883, 6884).
494        assert!(decision.to_unchoke.contains(&addr(6882)));
495        assert!(decision.to_unchoke.contains(&addr(6885)));
496        assert!(decision.to_unchoke.contains(&addr(6883)));
497        assert!(decision.to_unchoke.contains(&addr(6884)));
498
499        // Optimistic should add one more from the remaining interested peers,
500        // so total unchoked should be 5 (4 regular + 1 optimistic).
501        assert_eq!(decision.to_unchoke.len(), 5);
502
503        // The remaining peer is choked.
504        assert_eq!(decision.to_choke.len(), 1);
505    }
506
507    #[test]
508    fn optimistic_rotation() {
509        let mut choker = Choker::new(4);
510        let peers = vec![
511            peer(6881, 500, true),
512            peer(6882, 400, true),
513            peer(6883, 300, true),
514            peer(6884, 200, true),
515            peer(6885, 100, true),
516            peer(6886, 50, true),
517        ];
518
519        let decision = choker.decide(&peers);
520
521        // After decide(), there should be an optimistic peer among the unchoked.
522        // The top 4 are 6881..6884. Optimistic is from remaining interested.
523        assert_eq!(decision.to_unchoke.len(), 5);
524
525        // The optimistic peer is one of the two not in the top-4 regular set.
526        let regular = [addr(6881), addr(6882), addr(6883), addr(6884)];
527        let opt: Vec<_> = decision
528            .to_unchoke
529            .iter()
530            .filter(|a| !regular.contains(a))
531            .copied()
532            .collect();
533        assert_eq!(opt.len(), 1);
534        let first_opt = opt[0];
535        assert!(first_opt == addr(6885) || first_opt == addr(6886));
536
537        // After rotation, the optimistic peer should change.
538        choker.rotate_optimistic(&peers);
539        // Run decide again to reflect the new optimistic.
540        let decision2 = choker.decide(&peers);
541        let opt2: Vec<_> = decision2
542            .to_unchoke
543            .iter()
544            .filter(|a| !regular.contains(a))
545            .copied()
546            .collect();
547        assert_eq!(opt2.len(), 1);
548        assert_ne!(opt2[0], first_opt);
549    }
550
551    #[test]
552    fn fewer_peers_than_slots() {
553        let mut choker = Choker::new(4);
554        let peers = vec![peer(6881, 100, true), peer(6882, 200, true)];
555
556        let decision = choker.decide(&peers);
557
558        // Both interested peers should be unchoked.
559        assert!(decision.to_unchoke.contains(&addr(6881)));
560        assert!(decision.to_unchoke.contains(&addr(6882)));
561        assert_eq!(decision.to_unchoke.len(), 2);
562        assert!(decision.to_choke.is_empty());
563    }
564
565    #[test]
566    fn no_interested_peers() {
567        let mut choker = Choker::new(4);
568        let peers = vec![
569            peer(6881, 100, false),
570            peer(6882, 200, false),
571            peer(6883, 300, false),
572        ];
573
574        let decision = choker.decide(&peers);
575
576        assert!(decision.to_unchoke.is_empty());
577        // All peers should be in to_choke.
578        assert_eq!(decision.to_choke.len(), 3);
579        assert!(decision.to_choke.contains(&addr(6881)));
580        assert!(decision.to_choke.contains(&addr(6882)));
581        assert!(decision.to_choke.contains(&addr(6883)));
582    }
583
584    #[test]
585    fn choke_below_threshold() {
586        let mut choker = Choker::new(2);
587        let peers = vec![
588            peer(6881, 500, true),
589            peer(6882, 400, true),
590            peer(6883, 100, true),
591            peer(6884, 50, true),
592            peer(6885, 200, false), // not interested
593        ];
594
595        let decision = choker.decide(&peers);
596
597        // Regular unchokes: top 2 = ports 6881 (500), 6882 (400).
598        assert!(decision.to_unchoke.contains(&addr(6881)));
599        assert!(decision.to_unchoke.contains(&addr(6882)));
600
601        // Optimistic adds one more interested peer (6883 or 6884).
602        assert_eq!(decision.to_unchoke.len(), 3);
603
604        // The non-unchoked peers should be in to_choke.
605        // That's 2 from the remaining: one interested + the uninterested peer.
606        assert_eq!(decision.to_choke.len(), 2);
607        // Uninterested peer is always choked.
608        assert!(decision.to_choke.contains(&addr(6885)));
609    }
610
611    #[test]
612    fn set_unchoke_slots_changes_capacity() {
613        let mut choker = Choker::new(2);
614        let peers = vec![
615            peer(6881, 500, true),
616            peer(6882, 400, true),
617            peer(6883, 300, true),
618            peer(6884, 200, true),
619            peer(6885, 100, true),
620        ];
621
622        let decision = choker.decide(&peers);
623        // 2 regular + 1 optimistic = 3 unchoked
624        assert_eq!(decision.to_unchoke.len(), 3);
625
626        // Increase slots to 4
627        choker.set_unchoke_slots(4);
628        let decision = choker.decide(&peers);
629        // 4 regular + 1 optimistic = 5 unchoked
630        assert_eq!(decision.to_unchoke.len(), 5);
631    }
632
633    #[test]
634    fn seed_mode_unchokes_by_upload_rate() {
635        let mut choker = Choker::new(2);
636        choker.set_seed_mode(true);
637
638        // In seed mode, peers are ranked by upload_rate (how fast we upload TO them)
639        let peers = vec![
640            seed_peer(6881, 100, true),
641            seed_peer(6882, 500, true),
642            seed_peer(6883, 300, true),
643            seed_peer(6884, 200, true),
644        ];
645
646        let decision = choker.decide(&peers);
647
648        // Top 2 by upload rate: 500 (6882), 300 (6883)
649        assert!(decision.to_unchoke.contains(&addr(6882)));
650        assert!(decision.to_unchoke.contains(&addr(6883)));
651
652        // Plus one optimistic
653        assert_eq!(decision.to_unchoke.len(), 3);
654    }
655
656    #[test]
657    fn upload_only_excluded_from_optimistic() {
658        let mut choker = Choker::new(2);
659        let peers = vec![
660            peer(6881, 500, true),
661            peer(6882, 400, true),
662            // These two are interested but upload-only — should never get optimistic slot
663            PeerInfo {
664                addr: addr(6883),
665                download_rate: 100,
666                upload_rate: 0,
667                interested: true,
668                upload_only: true,
669                is_seed: false,
670            },
671            PeerInfo {
672                addr: addr(6884),
673                download_rate: 50,
674                upload_rate: 0,
675                interested: true,
676                upload_only: true,
677                is_seed: false,
678            },
679        ];
680
681        let decision = choker.decide(&peers);
682
683        // Regular unchokes: 6881, 6882 (top 2 by rate)
684        // No optimistic: both remaining interested peers are upload-only
685        assert_eq!(decision.to_unchoke.len(), 2);
686        assert!(decision.to_unchoke.contains(&addr(6881)));
687        assert!(decision.to_unchoke.contains(&addr(6882)));
688
689        // Upload-only peers are choked
690        assert!(decision.to_choke.contains(&addr(6883)));
691        assert!(decision.to_choke.contains(&addr(6884)));
692    }
693
694    #[test]
695    fn upload_only_still_regular_unchoked() {
696        // In seed mode, upload-only peers can earn regular unchoke by upload rate
697        let mut choker = Choker::new(2);
698        choker.set_seed_mode(true);
699
700        let peers = vec![
701            PeerInfo {
702                addr: addr(6881),
703                download_rate: 0,
704                upload_rate: 500,
705                interested: true,
706                upload_only: true, // upload-only but high upload rate
707                is_seed: false,
708            },
709            seed_peer(6882, 300, true),
710            seed_peer(6883, 100, true),
711        ];
712
713        let decision = choker.decide(&peers);
714
715        // Upload-only peer at 6881 has highest upload rate, should be in regular unchokes
716        assert!(decision.to_unchoke.contains(&addr(6881)));
717        assert!(decision.to_unchoke.contains(&addr(6882)));
718    }
719
720    // -----------------------------------------------------------------------
721    // New tests: algorithm enums
722    // -----------------------------------------------------------------------
723
724    #[test]
725    fn seed_choking_algorithm_default() {
726        assert_eq!(
727            SeedChokingAlgorithm::default(),
728            SeedChokingAlgorithm::FastestUpload
729        );
730    }
731
732    #[test]
733    fn choking_algorithm_default() {
734        assert_eq!(ChokingAlgorithm::default(), ChokingAlgorithm::FixedSlots);
735    }
736
737    #[test]
738    fn seed_choking_algorithm_serde_round_trip() {
739        for variant in [
740            SeedChokingAlgorithm::FastestUpload,
741            SeedChokingAlgorithm::RoundRobin,
742            SeedChokingAlgorithm::AntiLeech,
743        ] {
744            let json = serde_json::to_string(&variant).unwrap();
745            let decoded: SeedChokingAlgorithm = serde_json::from_str(&json).unwrap();
746            assert_eq!(decoded, variant);
747        }
748    }
749
750    #[test]
751    fn choking_algorithm_serde_round_trip() {
752        for variant in [ChokingAlgorithm::FixedSlots, ChokingAlgorithm::RateBased] {
753            let json = serde_json::to_string(&variant).unwrap();
754            let decoded: ChokingAlgorithm = serde_json::from_str(&json).unwrap();
755            assert_eq!(decoded, variant);
756        }
757    }
758
759    // -----------------------------------------------------------------------
760    // New tests: FixedSlotsStrategy
761    // -----------------------------------------------------------------------
762
763    #[test]
764    fn fixed_slots_strategy_leech_mode() {
765        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::FastestUpload);
766        let peers = vec![
767            peer(6881, 100, true),
768            peer(6882, 500, true),
769            peer(6883, 300, true),
770            peer(6884, 200, true),
771            peer(6885, 400, true),
772            peer(6886, 50, true),
773        ];
774
775        let decision = strategy.decide(&peers, 4, false);
776
777        // Top 4 by download_rate: 500 (6882), 400 (6885), 300 (6883), 200 (6884).
778        assert!(decision.to_unchoke.contains(&addr(6882)));
779        assert!(decision.to_unchoke.contains(&addr(6885)));
780        assert!(decision.to_unchoke.contains(&addr(6883)));
781        assert!(decision.to_unchoke.contains(&addr(6884)));
782
783        // 4 regular + 1 optimistic = 5
784        assert_eq!(decision.to_unchoke.len(), 5);
785        assert_eq!(decision.to_choke.len(), 1);
786    }
787
788    #[test]
789    fn fixed_slots_round_robin_rotates() {
790        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::RoundRobin);
791        // 5 interested peers, 2 unchoke slots — should rotate through them.
792        let peers = vec![
793            seed_peer(6881, 100, true),
794            seed_peer(6882, 200, true),
795            seed_peer(6883, 300, true),
796            seed_peer(6884, 400, true),
797            seed_peer(6885, 500, true),
798        ];
799
800        let d1 = strategy.decide(&peers, 2, true);
801        let d2 = strategy.decide(&peers, 2, true);
802
803        // The two rounds should select different regular unchoke sets because
804        // the offset advances by unchoke_slots each round.
805        // Extract the first two from each (regular unchokes before optimistic).
806        // We just need to verify they are not identical sets.
807        let set1: Vec<SocketAddr> = d1.to_unchoke.iter().copied().collect();
808        let set2: Vec<SocketAddr> = d2.to_unchoke.iter().copied().collect();
809        assert_ne!(set1, set2, "round-robin should rotate unchoke set");
810    }
811
812    #[test]
813    fn fixed_slots_anti_leech_prefers_non_seeds() {
814        let mut strategy = FixedSlotsStrategy::new(SeedChokingAlgorithm::AntiLeech);
815        let peers = vec![
816            // Two seed peers (is_seed = true)
817            PeerInfo {
818                addr: addr(6881),
819                download_rate: 0,
820                upload_rate: 500,
821                interested: true,
822                upload_only: false,
823                is_seed: true,
824            },
825            PeerInfo {
826                addr: addr(6882),
827                download_rate: 0,
828                upload_rate: 400,
829                interested: true,
830                upload_only: false,
831                is_seed: true,
832            },
833            // Two leecher peers (is_seed = false) with lower upload rates
834            PeerInfo {
835                addr: addr(6883),
836                download_rate: 0,
837                upload_rate: 100,
838                interested: true,
839                upload_only: false,
840                is_seed: false,
841            },
842            PeerInfo {
843                addr: addr(6884),
844                download_rate: 0,
845                upload_rate: 50,
846                interested: true,
847                upload_only: false,
848                is_seed: false,
849            },
850        ];
851
852        // 2 slots: anti-leech should prefer the 2 non-seed peers over seeds.
853        let decision = strategy.decide(&peers, 2, true);
854
855        // The regular unchokes should be the two leechers (non-seeds).
856        assert!(
857            decision.to_unchoke.contains(&addr(6883)),
858            "non-seed peer 6883 should be unchoked"
859        );
860        assert!(
861            decision.to_unchoke.contains(&addr(6884)),
862            "non-seed peer 6884 should be unchoked"
863        );
864    }
865
866    // -----------------------------------------------------------------------
867    // New tests: RateBasedStrategy
868    // -----------------------------------------------------------------------
869
870    #[test]
871    fn rate_based_starts_at_min_slots() {
872        let strategy = RateBasedStrategy::new(
873            SeedChokingAlgorithm::FastestUpload,
874            0,  // unlimited
875            3,  // min_slots
876            10, // max_slots
877        );
878        // min_slots=3, max(3,2)=3
879        assert_eq!(strategy.current_slots(), 3);
880
881        // If min_slots < 2, should start at 2.
882        let strategy2 = RateBasedStrategy::new(
883            SeedChokingAlgorithm::FastestUpload,
884            0,
885            1, // min_slots=1
886            10,
887        );
888        assert_eq!(strategy2.current_slots(), 2);
889    }
890
891    #[test]
892    fn rate_based_increases_slots_on_throughput_increase() {
893        let mut strategy = RateBasedStrategy::new(
894            SeedChokingAlgorithm::FastestUpload,
895            0,  // unlimited
896            2,  // min_slots
897            10, // max_slots
898        );
899        assert_eq!(strategy.current_slots(), 2);
900
901        // First observation sets baseline.
902        strategy.observe_throughput_inner(1000);
903        assert_eq!(strategy.current_slots(), 2);
904
905        // Throughput increased — should add a slot.
906        strategy.observe_throughput_inner(1500);
907        assert_eq!(strategy.current_slots(), 3);
908    }
909
910    #[test]
911    fn rate_based_decreases_slots_on_throughput_drop() {
912        let mut strategy = RateBasedStrategy::new(
913            SeedChokingAlgorithm::FastestUpload,
914            0,  // unlimited
915            2,  // min_slots
916            10, // max_slots
917        );
918
919        // Build up to 4 slots.
920        strategy.observe_throughput_inner(1000);
921        strategy.observe_throughput_inner(2000);
922        assert_eq!(strategy.current_slots(), 3);
923        strategy.observe_throughput_inner(3000);
924        assert_eq!(strategy.current_slots(), 4);
925
926        // Drop >10%: 3000 -> 2000 (33% drop).
927        strategy.observe_throughput_inner(2000);
928        assert_eq!(strategy.current_slots(), 3);
929    }
930
931    #[test]
932    fn rate_based_respects_min_max() {
933        let mut strategy = RateBasedStrategy::new(
934            SeedChokingAlgorithm::FastestUpload,
935            0, // unlimited
936            2, // min_slots
937            3, // max_slots = 3
938        );
939
940        // Build up.
941        strategy.observe_throughput_inner(1000);
942        strategy.observe_throughput_inner(2000);
943        assert_eq!(strategy.current_slots(), 3);
944
945        // Try to exceed max — should stay at 3.
946        strategy.observe_throughput_inner(5000);
947        assert_eq!(strategy.current_slots(), 3);
948
949        // Drop to bring down.
950        strategy.observe_throughput_inner(1000);
951        assert_eq!(strategy.current_slots(), 2);
952
953        // Drop more — should not go below min.
954        strategy.observe_throughput_inner(100);
955        assert_eq!(strategy.current_slots(), 2);
956    }
957
958    #[test]
959    fn rate_based_does_not_add_at_capacity() {
960        let mut strategy = RateBasedStrategy::new(
961            SeedChokingAlgorithm::FastestUpload,
962            10_000, // 10 KB/s limit
963            2,
964            10,
965        );
966
967        // Baseline.
968        strategy.observe_throughput_inner(5_000);
969        assert_eq!(strategy.current_slots(), 2);
970
971        // Throughput increased but >90% of capacity (9500/10000 = 95%).
972        strategy.observe_throughput_inner(9_500);
973        // Should NOT add slot because we're at capacity.
974        // However, we might still be at 2 because throughput increase check
975        // fails the at_capacity guard. The headroom check also shouldn't fire
976        // because headroom (500) < per_slot_avg (4750).
977        assert_eq!(strategy.current_slots(), 2);
978    }
979
980    #[test]
981    fn rate_based_ignores_external_unchoke_slots() {
982        let mut strategy = RateBasedStrategy::new(SeedChokingAlgorithm::FastestUpload, 0, 2, 10);
983
984        let peers = vec![
985            peer(6881, 500, true),
986            peer(6882, 400, true),
987            peer(6883, 300, true),
988            peer(6884, 200, true),
989            peer(6885, 100, true),
990        ];
991
992        // dynamic_slots = 2 (min). Pass unchoke_slots=10 externally — should be ignored.
993        let decision = strategy.decide(&peers, 10, false);
994
995        // Should use dynamic_slots=2, not the external 10.
996        // 2 regular + 1 optimistic = 3 unchoked.
997        assert_eq!(decision.to_unchoke.len(), 3);
998    }
999
1000    // -----------------------------------------------------------------------
1001    // New tests: Choker dispatcher
1002    // -----------------------------------------------------------------------
1003
1004    #[test]
1005    fn choker_with_round_robin() {
1006        let mut choker = Choker::with_algorithms(
1007            2,
1008            SeedChokingAlgorithm::RoundRobin,
1009            ChokingAlgorithm::FixedSlots,
1010            0,
1011            2,
1012            10,
1013        );
1014        choker.set_seed_mode(true);
1015
1016        let peers = vec![
1017            seed_peer(6881, 100, true),
1018            seed_peer(6882, 200, true),
1019            seed_peer(6883, 300, true),
1020            seed_peer(6884, 400, true),
1021            seed_peer(6885, 500, true),
1022        ];
1023
1024        let d1 = choker.decide(&peers);
1025        let d2 = choker.decide(&peers);
1026
1027        // Round-robin should produce different unchoke sets on successive calls.
1028        let set1: Vec<SocketAddr> = d1.to_unchoke.iter().copied().collect();
1029        let set2: Vec<SocketAddr> = d2.to_unchoke.iter().copied().collect();
1030        assert_ne!(set1, set2, "round-robin dispatcher should rotate");
1031    }
1032
1033    #[test]
1034    fn choker_with_rate_based() {
1035        let mut choker = Choker::with_algorithms(
1036            4, // this should be ignored by rate-based
1037            SeedChokingAlgorithm::FastestUpload,
1038            ChokingAlgorithm::RateBased,
1039            0,
1040            2,
1041            10,
1042        );
1043        assert_eq!(choker.choking_algorithm(), ChokingAlgorithm::RateBased);
1044
1045        let peers = vec![
1046            peer(6881, 500, true),
1047            peer(6882, 400, true),
1048            peer(6883, 300, true),
1049            peer(6884, 200, true),
1050            peer(6885, 100, true),
1051        ];
1052
1053        // Rate-based starts at min_slots=2, so 2 regular + 1 optimistic = 3.
1054        let decision = choker.decide(&peers);
1055        assert_eq!(decision.to_unchoke.len(), 3);
1056
1057        // Feed throughput to increase slots.
1058        choker.observe_throughput(1000);
1059        choker.observe_throughput(2000);
1060        // Now dynamic_slots should be 3 (increased).
1061        let decision = choker.decide(&peers);
1062        assert_eq!(decision.to_unchoke.len(), 4); // 3 regular + 1 optimistic
1063    }
1064
1065    #[test]
1066    fn choker_unchoke_slots_getter() {
1067        let mut choker = Choker::new(4);
1068        assert_eq!(choker.unchoke_slots(), 4);
1069
1070        choker.set_unchoke_slots(7);
1071        assert_eq!(choker.unchoke_slots(), 7);
1072
1073        choker.set_unchoke_slots(0);
1074        assert_eq!(choker.unchoke_slots(), 0);
1075    }
1076
1077    #[test]
1078    fn choker_new_is_backward_compatible() {
1079        let mut choker = Choker::new(4);
1080        assert_eq!(choker.choking_algorithm(), ChokingAlgorithm::FixedSlots);
1081
1082        let peers = vec![
1083            peer(6881, 500, true),
1084            peer(6882, 400, true),
1085            peer(6883, 300, true),
1086            peer(6884, 200, true),
1087            peer(6885, 100, true),
1088            peer(6886, 50, true),
1089        ];
1090
1091        let decision = choker.decide(&peers);
1092
1093        // Same behavior as old Choker: top 4 + 1 optimistic = 5.
1094        assert_eq!(decision.to_unchoke.len(), 5);
1095        assert!(decision.to_unchoke.contains(&addr(6882)));
1096        assert!(decision.to_unchoke.contains(&addr(6885)));
1097        assert!(decision.to_unchoke.contains(&addr(6883)));
1098        assert!(decision.to_unchoke.contains(&addr(6884)));
1099
1100        // set_seed_mode and set_unchoke_slots still work.
1101        choker.set_seed_mode(true);
1102        choker.set_unchoke_slots(2);
1103        let decision = choker.decide(&peers);
1104        assert_eq!(decision.to_unchoke.len(), 3); // 2 regular + 1 optimistic
1105    }
1106}