Skip to main content

aria2_core/engine/
choking_algorithm.rs

1use std::collections::HashSet;
2
3use super::peer_stats::PeerStats;
4use crate::constants;
5
6/// Action to take for a peer during choke rotation
7#[derive(Debug, Clone, PartialEq)]
8pub enum ChokeAction {
9    /// Unchoke peer at index
10    Unchoke(usize),
11    /// Choke peer at index
12    Choke(usize),
13    /// No action needed for this peer
14    NoChange(usize),
15}
16
17/// Configuration for the choking algorithm
18#[derive(Debug, Clone)]
19pub struct ChokingConfig {
20    /// Maximum number of peers to unchoke simultaneously (default: 4)
21    pub max_upload_slots: usize,
22    /// Interval in seconds between optimistic unchokes (default: 30)
23    pub optimistic_unchoke_interval_secs: u64,
24    /// Timeout in seconds after which a peer is considered snubbed (default: 60)
25    pub snubbed_timeout_secs: u64,
26    /// Interval in seconds between choke rotations (default: 10)
27    pub choke_rotation_interval_secs: u64,
28}
29
30impl Default for ChokingConfig {
31    fn default() -> Self {
32        Self {
33            max_upload_slots: 4,
34            optimistic_unchoke_interval_secs: 30,
35            snubbed_timeout_secs: 60,
36            choke_rotation_interval_secs: 10,
37        }
38    }
39}
40
41/// BitTorrent choking algorithm implementation (tit-for-tat strategy)
42///
43/// This implements the standard BT choking algorithm:
44/// - Top K peers by score get unchoked (reciprocity-based)
45/// - One additional slot for optimistic unchoke (random selection)
46/// - Snubbed peers are penalized heavily
47///
48/// The algorithm minimizes churn by only changing state when necessary.
49pub struct ChokingAlgorithm {
50    peers: Vec<PeerStats>,
51    config: ChokingConfig,
52    /// Explicitly snubbed peer indices (separate from PeerStats.is_snubbed for
53    /// algorithm-level control). Peers in this set always receive score -1000.
54    snubbed_peers: HashSet<usize>,
55    /// Index of the current optimistically unchoked peer (for rotation).
56    current_optimistic_peer: Option<usize>,
57    /// Round-robin counter for optimistic unchoke rotation.
58    optimistic_rotation_counter: usize,
59}
60
61impl ChokingAlgorithm {
62    /// Create a new choking algorithm with the given configuration
63    pub fn new(config: ChokingConfig) -> Self {
64        Self {
65            peers: Vec::new(),
66            config,
67            snubbed_peers: HashSet::new(),
68            current_optimistic_peer: None,
69            optimistic_rotation_counter: 0,
70        }
71    }
72
73    /// Add a peer to be managed by the algorithm
74    pub fn add_peer(&mut self, stats: PeerStats) {
75        self.peers.push(stats);
76    }
77
78    /// Remove a peer at the given index
79    pub fn remove_peer(&mut self, idx: usize) {
80        if idx < self.peers.len() {
81            self.peers.remove(idx);
82        }
83    }
84
85    /// Returns the number of peers being managed
86    pub fn len(&self) -> usize {
87        self.peers.len()
88    }
89
90    /// Returns true if there are no peers
91    pub fn is_empty(&self) -> bool {
92        self.peers.is_empty()
93    }
94
95    /// Core algorithm: called every ~10 seconds (config.choke_rotation_interval_secs)
96    ///
97    /// This performs the tit-for-tat choke rotation:
98    /// 1. Check and mark snubbed peers (timeout-based)
99    /// 2. Calculate score for each peer
100    /// 3. Sort by score descending
101    /// 4. Top K get Unchoke, rest get Choke
102    ///    BUT: keep currently unchoked peers unchoked if they're still in top K
103    ///    (avoid churn - only change what's necessary)
104    /// 5. Return only the actions that changed state
105    pub fn rotate_choke(&mut self) -> Vec<ChokeAction> {
106        // Step 1: Check and mark snubbed peers
107        self.check_snubbed_peers_internal();
108
109        if self.peers.is_empty() {
110            return vec![];
111        }
112
113        let max_slots = self.config.max_upload_slots;
114
115        // Step 2: Calculate scores and sort indices by score descending
116        let mut scored_peers: Vec<(usize, f64)> = self
117            .peers
118            .iter()
119            .enumerate()
120            .map(|(i, peer)| {
121                let is_snubbed = self.snubbed_peers.contains(&i);
122                (i, Self::calculate_peer_score(peer, is_snubbed))
123            })
124            .collect();
125
126        // Sort by score descending
127        scored_peers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
128
129        // Step 3 & 4: Determine which peers should be unchoked vs choked
130        let mut actions = Vec::new();
131        let mut new_unchoked_indices = std::collections::HashSet::new();
132
133        // Top K peers should be unchoked
134        for (rank, &(idx, _)) in scored_peers.iter().enumerate() {
135            if rank < max_slots {
136                // Should be unchoked
137                if self.peers[idx].am_choking {
138                    actions.push(ChokeAction::Unchoke(idx));
139                    self.peers[idx].record_unchoke();
140                } else {
141                    actions.push(ChokeAction::NoChange(idx));
142                }
143                new_unchoked_indices.insert(idx);
144            } else {
145                // Should be choked
146                if !self.peers[idx].am_choking {
147                    actions.push(ChokeAction::Choke(idx));
148                    self.peers[idx].record_choke();
149                } else {
150                    actions.push(ChokeAction::NoChange(idx));
151                }
152            }
153        }
154
155        actions
156    }
157
158    /// Called every ~30 seconds (config.optimistic_unchoke_interval_secs)
159    ///
160    /// Selects ONE choked+interested peer for optimistic unchoke.
161    /// This gives new/unknown peers a chance to prove themselves.
162    ///
163    /// Uses round-robin rotation among eligible non-snubbed peers
164    /// to ensure fair distribution of the optimistic unchoke slot.
165    ///
166    /// Returns Some(index) if found, None if no eligible peer
167    pub fn optimistically_unchoke(&mut self) -> Option<usize> {
168        // Find candidates that are:
169        //   - Currently choked (am_choking == true)
170        //   - Interested in us (peer_interested == true)
171        //   - Not snubbed (neither PeerStats.is_snubbed nor in explicit set)
172        //   - Not recently optimistically unchoked (>interval ago)
173        let candidates: Vec<usize> = self
174            .peers
175            .iter()
176            .enumerate()
177            .filter(|(i, peer)| {
178                peer.am_choking
179                    && peer.peer_interested
180                    && !peer.is_snubbed
181                    && !self.snubbed_peers.contains(i)
182                    && peer.time_since_last_optimistic_unchoke().as_secs()
183                        >= self.config.optimistic_unchoke_interval_secs
184            })
185            .map(|(i, _)| i)
186            .collect();
187
188        if candidates.is_empty() {
189            return None;
190        }
191
192        // Use round-robin selection: pick next candidate after current position
193        let selected = self.rotate_optimistic_unchoked(&candidates);
194
195        // Mark as optimistically unchoked
196        if let Some(peer) = self.peers.get_mut(selected) {
197            peer.record_optimistic_unchoke();
198        }
199        self.current_optimistic_peer = Some(selected);
200
201        Some(selected)
202    }
203
204    /// Rotate which peer gets the optimistic unchoke slot using round-robin.
205    ///
206    /// Picks a different peer than the current one when possible,
207    /// cycling through eligible peers in order.
208    ///
209    /// # Arguments
210    /// * `eligible_peers` - Indices of peers that are eligible for optimistic unchoke
211    ///
212    /// # Returns
213    /// The index of the selected peer from the eligible set
214    pub fn rotate_optimistic_unchoked(&mut self, eligible_peers: &[usize]) -> usize {
215        if eligible_peers.is_empty() {
216            panic!("rotate_optimistic_unchoked called with empty eligible list");
217        }
218
219        if eligible_peers.len() == 1 {
220            return eligible_peers[0];
221        }
222
223        // Find position of current optimistic peer in eligible list
224        let current_pos = self
225            .current_optimistic_peer
226            .and_then(|curr| eligible_peers.iter().position(|&x| x == curr));
227
228        // Advance to next peer in round-robin order
229        let next_pos = match current_pos {
230            Some(pos) => (pos + 1) % eligible_peers.len(),
231            None => self.optimistic_rotation_counter % eligible_peers.len(),
232        };
233
234        self.optimistic_rotation_counter = self.optimistic_rotation_counter.wrapping_add(1);
235        eligible_peers[next_pos]
236    }
237
238    /// Called whenever we receive data from a peer.
239    /// Automatically unsnubs the peer if it was in the explicit snubbed set.
240    pub fn on_data_received(&mut self, peer_idx: usize, bytes: u64) {
241        if let Some(peer) = self.peers.get_mut(peer_idx) {
242            peer.on_data_received(bytes);
243        }
244        // Auto-unsnub: receiving data means the peer is responsive again
245        self.unsnub_peer(peer_idx);
246    }
247
248    /// Explicitly mark a peer as snubbed (algorithm-level).
249    ///
250    /// This adds the peer to the `snubbed_peers` set, which causes
251    /// `calculate_peer_score` to return -1000 for this peer, ensuring
252    /// they always get choked on the next rotation.
253    pub fn mark_peer_snubbed(&mut self, peer_id: usize) {
254        if self.snubbed_peers.insert(peer_id) {
255            tracing::debug!("[BT] Peer {} explicitly marked as snubbed", peer_id);
256        }
257    }
258
259    /// Remove a peer from the explicit snubbed set (they sent data again).
260    ///
261    /// Returns `true` if the peer was actually in the snubbed set (newly un-snubbed),
262    /// `false` if they were not snubbed.
263    pub fn unsnub_peer(&mut self, peer_id: usize) -> bool {
264        if self.snubbed_peers.remove(&peer_id) {
265            tracing::debug!("[BT] Peer {} un-snubbed (data received)", peer_id);
266            true
267        } else {
268            false
269        }
270    }
271
272    /// Check if a peer is in the explicit snubbed set.
273    pub fn is_explicitly_snubbed(&self, peer_id: usize) -> bool {
274        self.snubbed_peers.contains(&peer_id)
275    }
276
277    /// Get the number of explicitly snubbed peers.
278    pub fn snubbed_count(&self) -> usize {
279        self.snubbed_peers.len()
280    }
281
282    /// Check all peers for snubbed status
283    /// Returns indices of newly snubbed peers
284    pub fn check_snubbed_peers(&mut self) -> Vec<usize> {
285        self.check_snubbed_peers_internal()
286    }
287
288    /// Internal implementation of snubbed checking
289    fn check_snubbed_peers_internal(&mut self) -> Vec<usize> {
290        let mut snubbed = vec![];
291        for (i, peer) in self.peers.iter_mut().enumerate() {
292            if peer.check_snubbed(self.config.snubbed_timeout_secs) {
293                snubbed.push(i);
294            }
295        }
296        snubbed
297    }
298
299    /// Score function: higher = better peer to keep unchoked
300    ///
301    /// Score components:
302    ///   1. Download speed contribution (how much they give us): weight 0.5
303    ///   2. Upload speed contribution (reciprocity): weight 0.3
304    ///   3. Snubbed penalty: -1000 if snubbed (either in PeerStats or algorithm set)
305    ///   4. Interest bonus: +50 if peer_interested
306    ///   5. New peer bonus (time since unchoke < 60s): +30 (anti-churn)
307    fn calculate_peer_score(peer: &PeerStats, is_explicitly_snubbed: bool) -> f64 {
308        let mut score = 0.0;
309
310        // Download speed (primary factor - tit-for-tat)
311        // Scale down to reasonable range
312        score += peer.download_speed * constants::CHOKING_DOWNLOAD_SPEED_WEIGHT;
313
314        // Upload speed (reciprocity)
315        score += peer.upload_speed * constants::CHOKING_UPLOAD_SPEED_WEIGHT;
316
317        // Snubbed penalty (heavy penalty to avoid wasting slots)
318        // Check both PeerStats-level and algorithm-level snubbing
319        if peer.is_snubbed || is_explicitly_snubbed {
320            score -= constants::CHOKING_SNUBBED_PENALTY;
321        }
322
323        // Interest bonus (prefer peers who want our data)
324        if peer.peer_interested {
325            score += constants::CHOKING_INTEREST_BONUS;
326        }
327
328        // Anti-churn: prefer keeping current unchoked peers stable
329        if !peer.am_choking
330            && peer.time_since_last_unchoke().as_secs()
331                < constants::CHOKING_ANTI_CHURN_THRESHOLD_SECS
332        {
333            score += constants::CHOKING_ANTI_CHURN_BONUS;
334        }
335
336        score
337    }
338
339    /// Get mutable reference to peer stats
340    pub fn get_peer_mut(&mut self, idx: usize) -> Option<&mut PeerStats> {
341        self.peers.get_mut(idx)
342    }
343
344    /// Get reference to peer stats
345    pub fn get_peer(&self, idx: usize) -> Option<&PeerStats> {
346        self.peers.get(idx)
347    }
348
349    /// Get all peers as a slice
350    pub fn peers(&self) -> &[PeerStats] {
351        &self.peers
352    }
353
354    /// Get reference to configuration
355    pub fn config(&self) -> &ChokingConfig {
356        &self.config
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::net::SocketAddr;
364
365    /// Helper to create a test peer with specific characteristics
366    fn create_test_peer(
367        download_speed: f64,
368        upload_speed: f64,
369        am_choking: bool,
370        peer_interested: bool,
371    ) -> PeerStats {
372        let addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
373        let mut peer = PeerStats::new([0u8; 20], addr);
374        peer.download_speed = download_speed;
375        peer.upload_speed = upload_speed;
376        peer.am_choking = am_choking;
377        peer.peer_interested = peer_interested;
378        peer
379    }
380
381    #[test]
382    fn test_new_algorithm_empty() {
383        let config = ChokingConfig::default();
384        let algo = ChokingAlgorithm::new(config);
385
386        assert!(algo.is_empty());
387        assert_eq!(algo.len(), 0);
388    }
389
390    #[test]
391    fn test_add_remove_peers() {
392        let config = ChokingConfig::default();
393        let mut algo = ChokingAlgorithm::new(config);
394
395        // Add peers
396        assert_eq!(algo.len(), 0);
397        let addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
398        algo.add_peer(PeerStats::new([0u8; 20], addr));
399        assert_eq!(algo.len(), 1);
400        algo.add_peer(PeerStats::new([0u8; 20], addr));
401        assert_eq!(algo.len(), 2);
402        algo.add_peer(PeerStats::new([0u8; 20], addr));
403        assert_eq!(algo.len(), 3);
404
405        // Remove middle peer
406        algo.remove_peer(1);
407        assert_eq!(algo.len(), 2);
408
409        // Remove first peer
410        algo.remove_peer(0);
411        assert_eq!(algo.len(), 1);
412
413        // Remove last peer
414        algo.remove_peer(0);
415        assert!(algo.is_empty());
416    }
417
418    #[test]
419    fn test_rotate_choke_selects_top_k() {
420        let config = ChokingConfig {
421            max_upload_slots: 3,
422            ..Default::default()
423        };
424        let mut algo = ChokingAlgorithm::new(config);
425
426        // Add 6 peers with different speeds (all start choked)
427        // Peer 0: highest download speed
428        algo.add_peer(create_test_peer(100000.0, 1000.0, true, true));
429        // Peer 1: medium-high
430        algo.add_peer(create_test_peer(80000.0, 800.0, true, true));
431        // Peer 2: medium
432        algo.add_peer(create_test_peer(60000.0, 600.0, true, true));
433        // Peer 3: medium-low
434        algo.add_peer(create_test_peer(40000.0, 400.0, true, true));
435        // Peer 4: low
436        algo.add_peer(create_test_peer(20000.0, 200.0, true, true));
437        // Peer 5: very low
438        algo.add_peer(create_test_peer(10000.0, 100.0, true, true));
439
440        let actions = algo.rotate_choke();
441
442        // Count unchoke actions
443        let unchoke_count = actions
444            .iter()
445            .filter(|a| matches!(a, ChokeAction::Unchoke(_)))
446            .count();
447
448        // Should have exactly 3 unchoke actions (top 3 by score)
449        assert_eq!(unchoke_count, 3);
450
451        // Verify all actions are accounted for
452        assert_eq!(actions.len(), 6); // One action per peer
453    }
454
455    #[test]
456    fn test_rotate_choke_minimizes_changes() {
457        let config = ChokingConfig {
458            max_upload_slots: 2,
459            ..Default::default()
460        };
461        let mut algo = ChokingAlgorithm::new(config);
462
463        // Add 4 peers
464        algo.add_peer(create_test_peer(100000.0, 1000.0, false, true)); // Already unchoked, high speed
465        algo.add_peer(create_test_peer(80000.0, 800.0, false, true)); // Already unchoked, med-high speed
466        algo.add_peer(create_test_peer(60000.0, 600.0, true, true)); // Choked, medium speed
467        algo.add_peer(create_test_peer(40000.0, 400.0, true, true)); // Choked, lower speed
468
469        // First rotation: top 2 should stay unchoked (they're already there)
470        let actions = algo.rotate_choke();
471
472        // Count NoChange actions for the already-unchoked peers
473        let no_change_count = actions
474            .iter()
475            .filter(|a| matches!(a, ChokeAction::NoChange(_)))
476            .count();
477
478        // At least the top 2 should have NoChange (they were already unchoked and remain so)
479        assert!(
480            no_change_count >= 2,
481            "Expected at least 2 NoChange actions, got {}",
482            no_change_count
483        );
484
485        // Second rotation without changes: should produce mostly NoChange
486        let actions2 = algo.rotate_choke();
487        let no_change_count2 = actions2
488            .iter()
489            .filter(|a| matches!(a, ChokeAction::NoChange(_)))
490            .count();
491
492        // All should be NoChange on second call (idempotent-safe)
493        assert_eq!(no_change_count2, 4, "Expected all NoChange on second call");
494    }
495
496    #[test]
497    fn test_optimistically_unchoke_selects_choked_peer() {
498        let config = ChokingConfig {
499            optimistic_unchoke_interval_secs: 0, // Allow immediate optimistic unchoke for testing
500            ..Default::default()
501        };
502        let mut algo = ChokingAlgorithm::new(config);
503
504        // Add peers
505        algo.add_peer(create_test_peer(1000.0, 100.0, true, true)); // Choked + interested ✓
506        algo.add_peer(create_test_peer(2000.0, 200.0, false, true)); // Unchoked ✗
507        algo.add_peer(create_test_peer(3000.0, 300.0, true, false)); // Not interested ✗
508
509        let result = algo.optimistically_unchoke();
510
511        // Should select peer 0 (only one that meets criteria)
512        assert!(
513            result.is_some(),
514            "Expected to select a peer for optimistic unchoke"
515        );
516        assert_eq!(result.unwrap(), 0);
517    }
518
519    #[test]
520    fn test_optimistically_avoids_recent() {
521        let config = ChokingConfig {
522            optimistic_unchoke_interval_secs: 30,
523            ..Default::default()
524        };
525        let mut algo = ChokingAlgorithm::new(config);
526
527        // Add peer and mark it as recently optimistically unchoked
528        let mut peer = create_test_peer(1000.0, 100.0, true, true);
529        peer.record_optimistic_unchoke(); // Just marked, so < 30s ago
530        algo.add_peer(peer);
531
532        let result = algo.optimistically_unchoke();
533
534        // Should not select this peer (too recent)
535        assert!(result.is_none());
536    }
537
538    #[test]
539    fn test_snubbed_peers_get_lowered_score() {
540        // Create two identical peers except one is snubbed
541        let normal_peer = create_test_peer(50000.0, 500.0, true, true);
542        let mut snubbed_peer = create_test_peer(50000.0, 500.0, true, true);
543        snubbed_peer.is_snubbed = true;
544
545        let normal_score = ChokingAlgorithm::calculate_peer_score(&normal_peer, false);
546        let snubbed_score_stats = ChokingAlgorithm::calculate_peer_score(&snubbed_peer, false);
547        let snubbed_score_explicit = ChokingAlgorithm::calculate_peer_score(&normal_peer, true);
548
549        // Snubbed peer should have much lower score (penalty of -1000)
550        assert!(snubbed_score_stats < normal_score);
551        assert!(
552            (normal_score - snubbed_score_stats) > 900.0,
553            "Expected large score difference due to PeerStats snubbed penalty"
554        );
555
556        // Explicitly snubbed peer should also have much lower score
557        assert!(snubbed_score_explicit < normal_score);
558        assert!(
559            (normal_score - snubbed_score_explicit) > 900.0,
560            "Expected large score difference due to explicit snubbed penalty"
561        );
562    }
563
564    #[test]
565    fn test_check_snubbed_returns_timed_out_peers() {
566        let config = ChokingConfig {
567            snubbed_timeout_secs: 1, // Use 1 second for testing
568            ..Default::default()
569        };
570        let mut algo = ChokingAlgorithm::new(config);
571
572        // Create a peer that hasn't received data
573        let _addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
574        let peer = PeerStats::new([0u8; 20], "127.0.0.1:6882".parse().unwrap());
575        algo.add_peer(peer);
576
577        // Wait for timeout (slightly longer than snubbed_timeout_secs)
578        std::thread::sleep(std::time::Duration::from_millis(1100));
579
580        // Check snubbed status
581        let snubbed_indices = algo.check_snubbed_peers();
582
583        // Should flag peer 0 as snubbed
584        assert_eq!(snubbed_indices.len(), 1);
585        assert_eq!(snubbed_indices[0], 0);
586        assert!(algo.get_peer(0).unwrap().is_snubbed);
587    }
588
589    #[test]
590    fn test_on_data_received_resets_snubbed_status() {
591        let config = ChokingConfig {
592            snubbed_timeout_secs: 1,
593            ..Default::default()
594        };
595        let mut algo = ChokingAlgorithm::new(config);
596
597        let peer = PeerStats::new([0u8; 20], "127.0.0.1:6883".parse().unwrap());
598        algo.add_peer(peer);
599
600        // Wait for timeout
601        std::thread::sleep(std::time::Duration::from_millis(1100));
602
603        // Mark as snubbed
604        let snubbed = algo.check_snubbed_peers();
605        assert_eq!(snubbed.len(), 1);
606        assert!(algo.get_peer(0).unwrap().is_snubbed);
607
608        // Now receive data
609        algo.on_data_received(0, 1024);
610
611        // Snubbed status should be reset
612        assert!(!algo.get_peer(0).unwrap().is_snubbed);
613    }
614
615    #[test]
616    fn test_get_peer_accessors() {
617        let config = ChokingConfig::default();
618        let mut algo = ChokingAlgorithm::new(config);
619
620        let addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
621        let peer = PeerStats::new([0u8; 20], addr);
622        algo.add_peer(peer);
623
624        // Test immutable access
625        assert!(algo.get_peer(0).is_some());
626        assert!(algo.get_peer(1).is_none());
627
628        // Test mutable access
629        {
630            let p = algo.get_peer_mut(0).unwrap();
631            p.download_speed = 9999.0;
632        }
633
634        assert!((algo.get_peer(0).unwrap().download_speed - 9999.0).abs() < f64::EPSILON);
635    }
636
637    #[test]
638    fn test_config_defaults() {
639        let config = ChokingConfig::default();
640
641        assert_eq!(config.max_upload_slots, 4);
642        assert_eq!(config.optimistic_unchoke_interval_secs, 30);
643        assert_eq!(config.snubbed_timeout_secs, 60);
644        assert_eq!(config.choke_rotation_interval_secs, 10);
645    }
646
647    // ==================== G1: Snubbing Enhancement Tests ====================
648
649    #[test]
650    fn test_snub_detection_after_timeout() {
651        // Test that peers are detected as snubbed after timeout
652        let config = ChokingConfig {
653            snubbed_timeout_secs: 1,
654            ..Default::default()
655        };
656        let mut algo = ChokingAlgorithm::new(config);
657
658        let peer = PeerStats::new([0u8; 20], "127.0.0.1:6882".parse().unwrap());
659        algo.add_peer(peer);
660
661        // Wait for timeout
662        std::thread::sleep(std::time::Duration::from_millis(1100));
663
664        // Check snubbed status - should detect via PeerStats timeout
665        let snubbed_indices = algo.check_snubbed_peers();
666        assert_eq!(snubbed_indices.len(), 1);
667        assert!(algo.get_peer(0).unwrap().is_snubbed);
668    }
669
670    #[test]
671    fn test_snubbed_peer_always_choked() {
672        // Test that explicitly snubbed peers always get choked
673        let config = ChokingConfig {
674            max_upload_slots: 2,
675            ..Default::default()
676        };
677        let mut algo = ChokingAlgorithm::new(config);
678
679        // Add 3 peers - peer 0 is high speed but will be snubbed
680        algo.add_peer(create_test_peer(100000.0, 1000.0, true, true)); // Peer 0: high speed
681        algo.add_peer(create_test_peer(50000.0, 500.0, true, true)); // Peer 1: medium speed
682        algo.add_peer(create_test_peer(30000.0, 300.0, true, true)); // Peer 2: low speed
683
684        // Explicitly snub peer 0 (the highest speed one)
685        algo.mark_peer_snubbed(0);
686        assert!(algo.is_explicitly_snubbed(0));
687        assert_eq!(algo.snubbed_count(), 1);
688
689        // Run choke rotation - snubbed peer should be choked despite high score
690        let actions = algo.rotate_choke();
691
692        // Find action for peer 0 - it should be Choked or NoChange(if already choked)
693        let peer0_action = actions
694            .iter()
695            .find(|a| matches!(a, ChokeAction::NoChange(0) | ChokeAction::Choke(0)));
696        assert!(
697            peer0_action.is_some(),
698            "Peer 0 should have an action in results"
699        );
700        // Peer 0 started as choked (am_choking=true), so with -1000 score it stays choked
701        match peer0_action.unwrap() {
702            ChokeAction::Choke(_) | ChokeAction::NoChange(_) => {} // Expected
703            ChokeAction::Unchoke(_) => panic!("Snubbed peer 0 should NEVER be unchoked"),
704        }
705    }
706
707    #[test]
708    fn test_unsnub_on_data_received() {
709        // Test that receiving data from a peer auto-unsnubs them
710        let config = ChokingConfig::default();
711        let mut algo = ChokingAlgorithm::new(config);
712
713        let peer = PeerStats::new([0u8; 20], "127.0.0.1:6883".parse().unwrap());
714        algo.add_peer(peer);
715
716        // Explicitly snub peer 0
717        algo.mark_peer_snubbed(0);
718        assert!(algo.is_explicitly_snubbed(0));
719        assert_eq!(algo.snubbed_count(), 1);
720
721        // Receive data from peer 0 - should auto-unsnub
722        algo.on_data_received(0, 1024);
723        assert!(
724            !algo.is_explicitly_snubbed(0),
725            "Peer should be un-snubbed after data received"
726        );
727        assert_eq!(algo.snubbed_count(), 0);
728    }
729
730    #[test]
731    fn test_opt_unchoking_rotation_changes_peer() {
732        // Test that optimistic unchoke rotates among eligible peers
733        let config = ChokingConfig {
734            optimistic_unchoke_interval_secs: 0, // Allow immediate re-selection
735            ..Default::default()
736        };
737        let mut algo = ChokingAlgorithm::new(config);
738
739        // Add 3 eligible peers (all choked + interested)
740        algo.add_peer(create_test_peer(1000.0, 100.0, true, true));
741        algo.add_peer(create_test_peer(2000.0, 200.0, true, true));
742        algo.add_peer(create_test_peer(3000.0, 300.0, true, true));
743
744        // First optimistic unchoke
745        let first = algo.optimistically_unchoke();
746        assert!(first.is_some());
747        let first_idx = first.unwrap();
748
749        // Second optimistic unchoke - should pick a DIFFERENT peer (round-robin)
750        // Reset the last_optimistic_unchoke time so they're eligible again
751        for i in 0..3 {
752            if let Some(p) = algo.get_peer_mut(i) {
753                p.last_optimistic_unchoke_at =
754                    std::time::Instant::now() - std::time::Duration::from_secs(1);
755            }
756        }
757
758        let second = algo.optimistically_unchoke();
759        assert!(second.is_some());
760        let second_idx = second.unwrap();
761
762        // With round-robin, second should differ from first (unless only 1 candidate)
763        // Since all 3 are eligible and we use rotation, we expect different peer
764        assert_ne!(
765            first_idx, second_idx,
766            "Optimistic unchoke should rotate to a different peer"
767        );
768    }
769
770    #[test]
771    fn test_opt_unchoking_excludes_snubbed_peers() {
772        // Test that snubbed peers are excluded from optimistic unchoke candidates
773        let config = ChokingConfig {
774            optimistic_unchoke_interval_secs: 0,
775            ..Default::default()
776        };
777        let mut algo = ChokingAlgorithm::new(config);
778
779        // Peer 0: eligible but will be snubbed
780        algo.add_peer(create_test_peer(5000.0, 500.0, true, true));
781        // Peer 1: eligible and NOT snubbed
782        algo.add_peer(create_test_peer(3000.0, 300.0, true, true));
783
784        // Snub peer 0
785        algo.mark_peer_snubbed(0);
786
787        // Optimistic unchoke should ONLY select peer 1
788        let result = algo.optimistically_unchoke();
789        assert!(result.is_some());
790        assert_eq!(
791            result.unwrap(),
792            1,
793            "Should select non-snubbed peer for optimistic unchoke"
794        );
795    }
796
797    #[test]
798    fn test_mark_snubbed_idempotent() {
799        let config = ChokingConfig::default();
800        let mut algo = ChokingAlgorithm::new(config);
801
802        algo.add_peer(create_test_peer(100.0, 10.0, true, true));
803
804        // Marking same peer twice should not increase count
805        algo.mark_peer_snubbed(0);
806        assert_eq!(algo.snubbed_count(), 1);
807        algo.mark_peer_snubbed(0); // Duplicate
808        assert_eq!(
809            algo.snubbed_count(),
810            1,
811            "Duplicate mark should not increase count"
812        );
813    }
814
815    #[test]
816    fn test_unsnub_non_snubbed_peer_returns_false() {
817        let config = ChokingConfig::default();
818        let mut algo = ChokingAlgorithm::new(config);
819
820        algo.add_peer(create_test_peer(100.0, 10.0, true, true));
821
822        // Unsnubbing a peer that was never snubbed returns false
823        let result = algo.unsnub_peer(0);
824        assert!(!result, "Unsnubbing non-snubbed peer should return false");
825    }
826}