diff --git a/biome.json b/biome.json index cd4b0fe6..88441e42 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": [ diff --git a/src-tauri/risuko-bt/src/bencode.rs b/src-tauri/risuko-bt/src/bencode.rs index ceb9e91d..a4b85eb9 100644 --- a/src-tauri/risuko-bt/src/bencode.rs +++ b/src-tauri/risuko-bt/src/bencode.rs @@ -1,15 +1,4 @@ //! Bencode codec (BEP-3) -//! -//! Provides: -//! - [`Value`] — an owned/borrowed bencode value tree -//! - [`decode`] / [`decode_all`] — streaming decoder that also records the -//! byte span of every decoded value; this lets callers recover the exact -//! raw bytes of a sub-value (needed to hash a torrent's `info` dict) -//! - [`encode_to_vec`] / [`encode_to_writer`] — canonical encoder that sorts -//! dict keys lexicographically -//! -//! The codec intentionally operates on byte slices only; text decoding is a -//! higher layer concern use std::collections::BTreeMap; use std::io::{self, Write}; @@ -32,21 +21,22 @@ pub enum Error { NonStringDictKey(usize), #[error("trailing bytes after decoded value")] TrailingBytes, + #[error("bencode input exceeds limit: len={len}, max={max}")] + InputLimit { len: usize, max: usize }, + #[error("bencode nesting exceeds limit at position {pos}: max depth={max}")] + DepthLimit { pos: usize, max: usize }, + #[error("bencode value count exceeds limit: max values={max}")] + ValueLimit { max: usize }, #[error("io error: {0}")] Io(#[from] io::Error), } /// A bencode value -/// -/// Variants hold owned data; borrow-friendly decoding can be layered on top -/// by holding `&Value` and byte spans #[derive(Debug, Clone, PartialEq, Eq)] pub enum Value { Int(i64), Bytes(Vec), List(Vec), - /// Dict preserves insertion order for round-tripping convenience; the - /// encoder always sorts on serialize so canonical output is guaranteed Dict(Vec<(Vec, Value)>), } @@ -88,24 +78,34 @@ impl Value { } } -/// Decoded value paired with the raw byte span it was parsed from +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeLimits { + pub max_input_len: usize, + pub max_depth: usize, + pub max_values: usize, +} + +impl DecodeLimits { + pub const fn new(max_input_len: usize, max_depth: usize, max_values: usize) -> Self { + Self { + max_input_len, + max_depth, + max_values, + } + } +} + #[derive(Debug, Clone)] pub struct Decoded { pub value: Value, - /// Half-open byte range within the original input pub span: std::ops::Range, } -/// Decode a single bencode value at the start of `input` -/// -/// Returns the decoded value and the number of bytes consumed pub fn decode(input: &[u8]) -> Result { - let mut p = Parser { buf: input, pos: 0 }; - let v = p.parse_value()?; - Ok(v) + let mut p = Parser::strict(input); + p.parse_value(0) } -/// Decode a single value and error if trailing bytes remain. pub fn decode_all(input: &[u8]) -> Result { let d = decode(input)?; if d.span.end != input.len() { @@ -114,12 +114,64 @@ pub fn decode_all(input: &[u8]) -> Result { Ok(d.value) } +pub fn decode_external(input: &[u8], limits: DecodeLimits) -> Result { + if input.len() > limits.max_input_len { + return Err(Error::InputLimit { + len: input.len(), + max: limits.max_input_len, + }); + } + let mut p = Parser::external(input, limits); + p.parse_value(0) +} + +/// Decode one bounded network value, allowing only trailing ASCII whitespace. +pub fn decode_all_external(input: &[u8], limits: DecodeLimits) -> Result { + let d = decode_external(input, limits)?; + if !input[d.span.end..] + .iter() + .all(|byte| byte.is_ascii_whitespace()) + { + return Err(Error::TrailingBytes); + } + Ok(d.value) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DictMode { + Canonical, + Relaxed, +} + struct Parser<'a> { buf: &'a [u8], pos: usize, + dict_mode: DictMode, + limits: Option, + values: usize, } impl<'a> Parser<'a> { + fn strict(buf: &'a [u8]) -> Self { + Self { + buf, + pos: 0, + dict_mode: DictMode::Canonical, + limits: None, + values: 0, + } + } + + fn external(buf: &'a [u8], limits: DecodeLimits) -> Self { + Self { + buf, + pos: 0, + dict_mode: DictMode::Relaxed, + limits: Some(limits), + values: 0, + } + } + fn peek(&self) -> Result { self.buf .get(self.pos) @@ -127,13 +179,32 @@ impl<'a> Parser<'a> { .ok_or(Error::UnexpectedEof(self.pos)) } - fn parse_value(&mut self) -> Result { + fn note_value(&mut self, depth: usize) -> Result<(), Error> { + if let Some(limits) = self.limits { + if depth > limits.max_depth { + return Err(Error::DepthLimit { + pos: self.pos, + max: limits.max_depth, + }); + } + if self.values >= limits.max_values { + return Err(Error::ValueLimit { + max: limits.max_values, + }); + } + } + self.values = self.values.saturating_add(1); + Ok(()) + } + + fn parse_value(&mut self, depth: usize) -> Result { + self.note_value(depth)?; let start = self.pos; let b = self.peek()?; let value = match b { b'i' => self.parse_int()?, - b'l' => self.parse_list()?, - b'd' => self.parse_dict()?, + b'l' => self.parse_list(depth)?, + b'd' => self.parse_dict(depth)?, b'0'..=b'9' => self.parse_bytes()?, other => { return Err(Error::UnexpectedByte { @@ -209,6 +280,12 @@ impl<'a> Parser<'a> { let digits = &self.buf[start..self.pos]; // skip ':' self.pos += 1; + if digits.len() > 1 && digits[0] == b'0' { + return Err(Error::BadLength { + pos: start, + reason: "leading zero", + }); + } let s = std::str::from_utf8(digits).map_err(|_| Error::BadLength { pos: start, reason: "non-ascii", @@ -234,34 +311,36 @@ impl<'a> Parser<'a> { Ok(Value::Bytes(bytes)) } - fn parse_list(&mut self) -> Result { + fn parse_list(&mut self, depth: usize) -> Result { debug_assert_eq!(self.buf[self.pos], b'l'); self.pos += 1; let mut items = Vec::new(); while self.peek()? != b'e' { - items.push(self.parse_value()?.value); + items.push(self.parse_value(depth.saturating_add(1))?.value); } self.pos += 1; Ok(Value::List(items)) } - fn parse_dict(&mut self) -> Result { + fn parse_dict(&mut self, depth: usize) -> Result { debug_assert_eq!(self.buf[self.pos], b'd'); self.pos += 1; let mut items: Vec<(Vec, Value)> = Vec::new(); while self.peek()? != b'e' { let key_pos = self.pos; - let key_val = self.parse_value()?.value; + let key_val = self.parse_value(depth.saturating_add(1))?.value; let key = match key_val { Value::Bytes(b) => b, _ => return Err(Error::NonStringDictKey(key_pos)), }; - if let Some((prev, _)) = items.last() { - if prev.as_slice() >= key.as_slice() { - return Err(Error::BadDictOrder(key_pos)); + if self.dict_mode == DictMode::Canonical { + if let Some((prev, _)) = items.last() { + if prev.as_slice() >= key.as_slice() { + return Err(Error::BadDictOrder(key_pos)); + } } } - let value = self.parse_value()?.value; + let value = self.parse_value(depth.saturating_add(1))?.value; items.push((key, value)); } self.pos += 1; @@ -275,7 +354,7 @@ pub fn decode_dict_field_raw<'a>( input: &'a [u8], key: &[u8], ) -> Result, Error> { - let mut p = Parser { buf: input, pos: 0 }; + let mut p = Parser::strict(input); if p.peek()? != b'd' { return Err(Error::UnexpectedByte { byte: input[0], @@ -284,12 +363,12 @@ pub fn decode_dict_field_raw<'a>( } p.pos += 1; while p.peek()? != b'e' { - let k = match p.parse_value()?.value { + let k = match p.parse_value(1)?.value { Value::Bytes(b) => b, _ => return Err(Error::NonStringDictKey(p.pos)), }; let val_start = p.pos; - let v = p.parse_value()?; + let v = p.parse_value(1)?; if k == key { let raw = &input[val_start..v.span.end]; return Ok(Some((v.value, raw))); @@ -392,6 +471,55 @@ mod tests { assert!(decode_all(b"d3:fooi1e3:fooi2ee").is_err()); } + #[test] + fn external_decode_accepts_unsorted_and_duplicate_keys_first_wins() { + let limits = DecodeLimits::new(128, 8, 32); + let value = decode_all_external(b"d3:fooi1e3:bari2e3:fooi3ee", limits).unwrap(); + let items = value.as_dict().unwrap(); + + assert_eq!(items.len(), 3); + assert_eq!(value.get(b"foo").and_then(Value::as_int), Some(1)); + } + + #[test] + fn external_decode_allows_only_whitespace_tail() { + let limits = DecodeLimits::new(128, 8, 32); + + assert_eq!( + decode_all_external(b"i7e \t\n", limits).unwrap(), + Value::Int(7) + ); + assert!(matches!( + decode_all_external(b"i7ejunk", limits), + Err(Error::TrailingBytes) + )); + } + + #[test] + fn external_decode_enforces_input_depth_and_value_limits() { + assert!(matches!( + decode_external(b"4:spam", DecodeLimits::new(5, 8, 8)), + Err(Error::InputLimit { len: 6, max: 5 }) + )); + assert!(matches!( + decode_all_external(b"lli1eee", DecodeLimits::new(16, 1, 8)), + Err(Error::DepthLimit { max: 1, .. }) + )); + assert!(matches!( + decode_all_external(b"li1ei2ee", DecodeLimits::new(16, 8, 2)), + Err(Error::ValueLimit { max: 2 }) + )); + } + + #[test] + fn external_decode_reports_consumed_header_before_binary_tail() { + let limits = DecodeLimits::new(128, 8, 32); + let decoded = decode_external(b"d1:ai1ee\0\xff", limits).unwrap(); + + assert_eq!(decoded.span, 0..8); + assert_eq!(decoded.value.get(b"a").and_then(Value::as_int), Some(1)); + } + #[test] fn encode_round_trip_sorts_dicts() { let v = Value::Dict(vec![ diff --git a/src-tauri/risuko-bt/src/dht.rs b/src-tauri/risuko-bt/src/dht.rs index 92e8ccfa..936f054e 100644 --- a/src-tauri/risuko-bt/src/dht.rs +++ b/src-tauri/risuko-bt/src/dht.rs @@ -1,6 +1,4 @@ //! Minimal BEP-5 DHT -//! Supports IPv4 and IPv6 (BEP-32) via two parallel UDP sockets when the -//! host has global v6 connectivity use std::collections::{BTreeMap, HashSet}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; @@ -13,7 +11,7 @@ use tokio::net::{lookup_host, UdpSocket}; use tokio::sync::{mpsc, oneshot}; use tokio::task::JoinSet; -use super::bencode::{decode_all, encode_to_vec, Value}; +use super::bencode::{decode_all_external, encode_to_vec, DecodeLimits, Value}; use super::core::Id20; /// Decoded `get_peers` reply: source addr, responder id (if present), @@ -38,6 +36,7 @@ const K: usize = 8; const ALPHA: usize = 3; const QUERY_TIMEOUT: Duration = Duration::from_secs(4); const MAX_ROUND_QUERIES: usize = 50; +const KRPC_DECODE_LIMITS: DecodeLimits = DecodeLimits::new(2048, 16, 1024); pub const DEFAULT_BOOTSTRAP: &[&str] = &[ "router.bittorrent.com:6881", @@ -235,21 +234,15 @@ impl Dht { return; } - // BTreeMap keyed by XOR distance to info_hash → candidate endpoints - // We keep the K closest "live" nodes we've heard responses from let mut shortlist: BTreeMap = BTreeMap::new(); let mut queried: HashSet = HashSet::new(); let mut peers_seen: HashSet = HashSet::new(); - // Nodes (keyed by XOR distance to the target) that returned a write - // token, paired with that token. After the lookup we announce - // ourselves to the closest of these (BEP-5) when announce_port is set let mut announce_targets: BTreeMap)> = BTreeMap::new(); - // Seed: ask bootstrap nodes with a dummy id = info_hash (so their - // responses contain nodes close to the target) - for a in addrs.iter().take(MAX_ROUND_QUERIES) { - queried.insert(*a); - } + let mut seed_seen = HashSet::new(); + addrs.retain(|addr| seed_seen.insert(*addr)); + addrs.truncate(MAX_ROUND_QUERIES); + queried.extend(addrs.iter().copied()); let mut futs: JoinSet> = JoinSet::new(); for a in addrs { @@ -714,7 +707,7 @@ async fn reader_loop(sock: Arc, pending: Arc>) { Ok(x) => x, Err(_) => return, }; - let Ok(msg) = decode_all(&buf[..n]) else { + let Ok(msg) = decode_all_external(&buf[..n], KRPC_DECODE_LIMITS) else { continue; }; let Some(ty) = msg.get(b"y").and_then(|v| v.as_bytes()) else { @@ -745,6 +738,7 @@ async fn reader_loop(sock: Arc, pending: Arc>) { #[cfg(test)] mod tests { use super::*; + use crate::bencode::decode_all; #[test] fn routing_table_inserts_dedupes_and_indexes_distance() { diff --git a/src-tauri/risuko-bt/src/magnet.rs b/src-tauri/risuko-bt/src/magnet.rs index 079da63a..77291296 100644 --- a/src-tauri/risuko-bt/src/magnet.rs +++ b/src-tauri/risuko-bt/src/magnet.rs @@ -1,9 +1,5 @@ //! Magnet URI → info-dict resolution //! -//! Discovers peers via user-supplied trackers and the process-wide warm DHT -//! (`Dht::shared`), then downloads the `info` dict from them using BEP-9 -//! (ut_metadata) - use std::collections::{BTreeMap, HashSet}; use std::net::SocketAddr; use std::sync::Arc; @@ -20,7 +16,7 @@ use super::core::{ generate_peer_id, parse_info_v2_from_bytes, Id20, Id32, Magnet, ValidatedTorrentMetaV2Info, }; use super::dht::Dht; -use super::peer::{connect, PeerCommand, PeerEvent, SpawnPeer}; +use super::peer::{connect_with_utp_fallback, PeerCommand, PeerEvent, SpawnPeer}; use super::tracker::{announce, AnnounceEvent, AnnounceRequest}; use super::wire::extended::{ parse_ut_metadata, ut_metadata_request, ut_metadata_type, ExtHandshake, EXT_HANDSHAKE_ID, @@ -34,29 +30,22 @@ const OUR_UT_PEX_ID: u8 = 4; const TRACKER_TIMEOUT: Duration = Duration::from_secs(10); const PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const PEER_READ_TIMEOUT: Duration = Duration::from_secs(10); -/// Per-peer ceiling: enough to fetch a full info dict + every file's piece -/// layer over `HASH_REQUEST` on a single connection without timing out const PEER_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); const MAX_CONCURRENT_PEERS: usize = 128; -/// Stable error-message prefix used by the engine error classifier to map a -/// failed pure-v2 magnet resolution onto a typed error code. Keep in sync -/// with `risuko-engine::engine::error_code` pub const ERR_PIECE_LAYERS_UNAVAILABLE: &str = "piece layers unavailable"; -/// Resolution result: the wire info-hash, raw bencoded info dict, the -/// optional v2 SHA-256 info-hash (when the magnet was hybrid or pure-v2), -/// and the union of trackers (magnet's `tr=` + caller-supplied) +/// Resolution result pub struct Resolved { pub info_hash: Id20, pub info_hash_v2: Option, pub info_bytes: Vec, pub trackers: Vec, - /// BEP 52 piece layers fetched from peers via `HASH_REQUEST`. Keyed by - /// each file's `pieces root`. Empty for v1-only magnets and for v2 - /// magnets whose every file fits in a single piece (no layer required) pub piece_layers: BTreeMap>, + pub peers: Vec, } +const DEFAULT_LISTEN_PORT: u16 = 6881; + /// Resolve a magnet URI to its raw info dict pub async fn resolve( magnet_uri: &str, @@ -64,18 +53,101 @@ pub async fn resolve( budget: Duration, encryption: crate::peer::EncryptionPolicy, ) -> Result { - resolve_with_peers(magnet_uri, extra_trackers, &[], budget, encryption).await + resolve_with_port( + magnet_uri, + extra_trackers, + DEFAULT_LISTEN_PORT, + budget, + encryption, + ) + .await +} + +/// Resolve a magnet while advertising the caller's real peer-listen port +pub async fn resolve_with_port( + magnet_uri: &str, + extra_trackers: &[String], + listen_port: u16, + budget: Duration, + encryption: crate::peer::EncryptionPolicy, +) -> Result { + resolve_with_port_and_utp( + magnet_uri, + extra_trackers, + listen_port, + budget, + encryption, + None, + ) + .await +} + +pub async fn resolve_with_port_and_utp( + magnet_uri: &str, + extra_trackers: &[String], + listen_port: u16, + budget: Duration, + encryption: crate::peer::EncryptionPolicy, + utp: Option>, +) -> Result { + resolve_with_peers_and_port_and_utp( + magnet_uri, + extra_trackers, + &[], + listen_port, + budget, + encryption, + utp, + ) + .await } -/// Like [`resolve`] but seeds the peer pool with explicitly known -/// addresses (in addition to tracker / DHT discovery). Used by tests and -/// callers that have cached peers from a prior session pub async fn resolve_with_peers( magnet_uri: &str, extra_trackers: &[String], extra_peers: &[SocketAddr], budget: Duration, encryption: crate::peer::EncryptionPolicy, +) -> Result { + resolve_with_peers_and_port( + magnet_uri, + extra_trackers, + extra_peers, + DEFAULT_LISTEN_PORT, + budget, + encryption, + ) + .await +} + +pub async fn resolve_with_peers_and_port( + magnet_uri: &str, + extra_trackers: &[String], + extra_peers: &[SocketAddr], + listen_port: u16, + budget: Duration, + encryption: crate::peer::EncryptionPolicy, +) -> Result { + resolve_with_peers_and_port_and_utp( + magnet_uri, + extra_trackers, + extra_peers, + listen_port, + budget, + encryption, + None, + ) + .await +} + +pub async fn resolve_with_peers_and_port_and_utp( + magnet_uri: &str, + extra_trackers: &[String], + extra_peers: &[SocketAddr], + listen_port: u16, + budget: Duration, + encryption: crate::peer::EncryptionPolicy, + utp: Option>, ) -> Result { let magnet = Magnet::parse(magnet_uri).map_err(|e| e.to_string())?; let info_hash = magnet.info_hash(); @@ -83,24 +155,21 @@ pub async fn resolve_with_peers( let want_v2 = magnet.info_hash_v2(); let advertise_v2 = want_v1.is_none() && want_v2.is_some(); - let mut trackers: Vec = magnet.trackers.clone(); - for t in extra_trackers { - if !trackers.iter().any(|x| x == t) { - trackers.push(t.clone()); + let mut trackers: Vec = Vec::new(); + for t in magnet.trackers.iter().chain(extra_trackers.iter()) { + for part in expand_tracker_entries(t) { + if !trackers.iter().any(|x| x == &part) { + trackers.push(part); + } } } let our_peer_id = generate_peer_id(); - let req = AnnounceRequest { - info_hash, - peer_id: our_peer_id, - port: 6881, - uploaded: 0, - downloaded: 0, - left: 0, - event: AnnounceEvent::Started, - num_want: 200, - }; + // Announce as a leecher (`left != 0`). `left: 0` marks us as a seeder and + // trackers then return other leechers — useless for metadata fetch and for + // the peer list we hand off to the download. Size is unknown until the + // info dict arrives, so use a large sentinel (common BT client practice). + let req = metadata_announce_request(info_hash, our_peer_id, listen_port); let deadline = Instant::now() + budget; let started = Instant::now(); @@ -155,8 +224,8 @@ pub async fn resolve_with_peers( } drop(peer_tx); - // First successful (info, piece_layers) pair wins via this oneshot - type ResolvedPayload = (Vec, BTreeMap>); + // First successful (info, piece_layers, winner_addr) triple wins via this oneshot + type ResolvedPayload = (Vec, BTreeMap>, SocketAddr); let (result_tx, result_rx) = oneshot::channel::(); let result_tx: Arc>>> = Arc::new(Mutex::new(Some(result_tx))); @@ -169,14 +238,29 @@ pub async fn resolve_with_peers( // Bound fan-out so we don't open thousands of sockets let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_PEERS)); + // Shared so we can hand discovered peers to the download after resolve. + // A fan-in task owns `peer_rx` and records every addr before dialing. + let discovered: Arc>> = Arc::new(Mutex::new(HashSet::new())); + let (dial_tx, mut dial_rx) = mpsc::unbounded_channel::(); + let fan_discovered = discovered.clone(); + let fan_in = tokio::spawn(async move { + while let Some(addr) = peer_rx.recv().await { + if !is_dialable_peer_addr(addr) { + continue; + } + if fan_discovered.lock().insert(addr) { + let _ = dial_tx.send(addr); + } + } + }); // Driver: consume peer addresses and spawn bounded fetch tasks let driver = { let result_tx = result_tx.clone(); let sem = sem.clone(); let layers_failed = layers_failed.clone(); + let utp = utp.clone(); async move { - let mut seen: HashSet = HashSet::new(); let mut joinset: JoinSet<()> = JoinSet::new(); loop { @@ -185,7 +269,7 @@ pub async fn resolve_with_peers( break; } tokio::select! { - maybe = peer_rx.recv() => { + maybe = dial_rx.recv() => { let Some(addr) = maybe else { // Trackers drained; wait for in-flight to finish while joinset.join_next().await.is_some() { @@ -193,7 +277,6 @@ pub async fn resolve_with_peers( } break; }; - if !seen.insert(addr) { continue; } let permit = match Arc::clone(&sem).acquire_owned().await { Ok(p) => p, @@ -201,11 +284,19 @@ pub async fn resolve_with_peers( }; let result_tx = result_tx.clone(); let layers_failed = layers_failed.clone(); + let utp = utp.clone(); joinset.spawn(async move { let _permit = permit; let fetched = tokio::time::timeout( PEER_TOTAL_TIMEOUT, - try_fetch_from_peer(addr, info_hash, our_peer_id, encryption, advertise_v2), + try_fetch_from_peer( + addr, + info_hash, + our_peer_id, + encryption, + advertise_v2, + utp, + ), ) .await .ok() @@ -240,7 +331,7 @@ pub async fn resolve_with_peers( if !layers_complete { if can_use_v1_metadata_without_piece_layers(want_v1, &bytes) { if let Some(tx) = result_tx.lock().take() { - let _ = tx.send((bytes, BTreeMap::new())); + let _ = tx.send((bytes, BTreeMap::new(), addr)); } return; } @@ -252,7 +343,7 @@ pub async fn resolve_with_peers( return; } if let Some(tx) = result_tx.lock().take() { - let _ = tx.send((bytes, layers)); + let _ = tx.send((bytes, layers, addr)); } }); } @@ -277,16 +368,33 @@ pub async fn resolve_with_peers( if let Some(h) = dht_handle { h.abort(); } + // Brief wait so fan-in can record peers already in-flight before senders drop + let _ = tokio::time::timeout(Duration::from_millis(200), fan_in).await; + + let peers: Vec = discovered.lock().iter().copied().collect(); match winner { - Some((info_bytes, piece_layers)) => { - tracing::info!("Resolved magnet in {:?}", started.elapsed()); + Some((info_bytes, piece_layers, winner_addr)) => { + // Prefer the peer that already served metadata — they are a proven + // live contact for the subsequent download. + let mut peers = peers; + if let Some(pos) = peers.iter().position(|a| *a == winner_addr) { + peers.swap(0, pos); + } else if is_dialable_peer_addr(winner_addr) { + peers.insert(0, winner_addr); + } + tracing::info!( + "Resolved magnet in {:?} ({} peers discovered, winner={winner_addr})", + started.elapsed(), + peers.len() + ); Ok(Resolved { info_hash, info_hash_v2: want_v2, info_bytes, trackers, piece_layers, + peers, }) } None => { @@ -306,6 +414,66 @@ pub async fn resolve_with_peers( } } +fn metadata_announce_request(info_hash: Id20, peer_id: Id20, listen_port: u16) -> AnnounceRequest { + AnnounceRequest { + info_hash, + peer_id, + port: listen_port, + uploaded: 0, + downloaded: 0, + left: u64::MAX / 2, + event: AnnounceEvent::Started, + num_want: 200, + } +} + +fn expand_tracker_entries(raw: &str) -> Vec { + raw.split([',', '\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +pub fn is_dialable_peer_addr(addr: SocketAddr) -> bool { + if addr.port() == 0 { + return false; + } + match addr.ip() { + std::net::IpAddr::V4(ip) => { + if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() || ip.is_link_local() { + return false; + } + // Cloudflare anycast shows up via PEX/DHT as "peers" that always + // time out and burn dial slots (162.158/15, 172.64/13, …). + !is_cloudflare_v4(ip) + } + std::net::IpAddr::V6(ip) => { + !(ip.is_unspecified() || ip.is_multicast() || ip.is_unicast_link_local()) + } + } +} + +fn is_cloudflare_v4(ip: std::net::Ipv4Addr) -> bool { + let o = ip.octets(); + matches!( + (o[0], o[1]), + (162, 158 | 159) // 162.158.0.0/15 + | (172, 64..=71) // 172.64.0.0/13 + | (104, 16..=31) // 104.16.0.0/12 (covers /13 published ranges) + | (173, 245) // 173.245.48.0/20 — coarse; rare false positives OK + | (108, 162) // 108.162.192.0/18 + | (141, 101) // 141.101.64.0/18 + | (188, 114) // 188.114.96.0/20 + | (190, 93) // 190.93.240.0/20 + | (197, 234) // 197.234.240.0/22 + | (198, 41) // 198.41.128.0/17 + ) || matches!( + (o[0], o[1], o[2]), + (103, 21, 244..=247) | (103, 22, 200..=203) | (103, 31, 4..=7) | (131, 0, 72..=75) + ) +} + fn can_use_v1_metadata_without_piece_layers(want_v1: Option, info_bytes: &[u8]) -> bool { if want_v1.is_none() { return false; @@ -337,6 +505,7 @@ async fn try_fetch_from_peer( our_peer_id: Id20, encryption: crate::peer::EncryptionPolicy, advertise_v2: bool, + utp: Option>, ) -> Option<(Vec, BTreeMap>, bool)> { // Build a per-peer extended-handshake builder. The connection layer // invokes it once with the peer's IP so `yourip` matches that peer — @@ -352,16 +521,19 @@ async fn try_fetch_from_peer( payload: hs.encode(), }) }); - let (handle, rx) = connect(SpawnPeer { - addr, - info_hash, - our_peer_id, - connect_timeout: PEER_CONNECT_TIMEOUT, - read_timeout: PEER_READ_TIMEOUT, - encryption, - advertise_v2, - ext_handshake_builder: Some(ext_handshake_builder), - }) + let (handle, rx) = connect_with_utp_fallback( + SpawnPeer { + addr, + info_hash, + our_peer_id, + connect_timeout: PEER_CONNECT_TIMEOUT, + read_timeout: PEER_READ_TIMEOUT, + encryption, + advertise_v2, + ext_handshake_builder: Some(ext_handshake_builder), + }, + utp, + ) .await .ok()?; @@ -682,6 +854,18 @@ pub fn synth_torrent_bytes( mod tests { use super::*; + #[test] + fn metadata_announce_uses_supplied_listen_port() { + let info_hash = Id20::from_slice(&[1u8; 20]).unwrap(); + let peer_id = Id20::from_slice(&[2u8; 20]).unwrap(); + let request = metadata_announce_request(info_hash, peer_id, 51_234); + + assert_eq!(request.port, 51_234); + assert_eq!(request.info_hash, info_hash); + assert_eq!(request.peer_id, peer_id); + assert_eq!(request.event, AnnounceEvent::Started); + } + #[test] fn synth_torrent_round_trips_through_parse() { use crate::bencode::{encode_to_vec, Value}; @@ -704,6 +888,21 @@ mod tests { ); } + #[test] + fn expand_tracker_entries_splits_newlines_and_commas() { + let parts = expand_tracker_entries( + "udp://a:1/announce\n\nudp://b:2/announce,http://c:3/announce\r\n", + ); + assert_eq!( + parts, + vec![ + "udp://a:1/announce".to_string(), + "udp://b:2/announce".to_string(), + "http://c:3/announce".to_string(), + ] + ); + } + #[test] fn v1_info_can_be_used_without_piece_layers() { use crate::bencode::{encode_to_vec, Value}; @@ -765,4 +964,17 @@ mod tests { assert!(meta.info_v2.is_some()); assert!(!crate::core::supports_v2_wire(&meta)); } + + #[test] + fn dialable_filter_drops_cloudflare_anycast() { + use std::net::{Ipv4Addr, SocketAddr}; + let cf: SocketAddr = (Ipv4Addr::new(162, 158, 179, 174), 6881).into(); + let cf2: SocketAddr = (Ipv4Addr::new(172, 71, 219, 4), 60329).into(); + let ok: SocketAddr = (Ipv4Addr::new(111, 193, 237, 96), 34000).into(); + let loopback: SocketAddr = (Ipv4Addr::LOCALHOST, 6881).into(); + assert!(!is_dialable_peer_addr(cf)); + assert!(!is_dialable_peer_addr(cf2)); + assert!(is_dialable_peer_addr(ok)); + assert!(is_dialable_peer_addr(loopback)); + } } diff --git a/src-tauri/risuko-bt/src/peer.rs b/src-tauri/risuko-bt/src/peer.rs index bb566b4d..af925e7b 100644 --- a/src-tauri/risuko-bt/src/peer.rs +++ b/src-tauri/risuko-bt/src/peer.rs @@ -15,7 +15,7 @@ pub mod connection; pub use connection::{ - accept, accept_utp_plaintext, connect, connect_utp_plaintext, connect_with_utp_fallback, - EncryptionPolicy, ExtHandshakeBuilder, KnownInfoHash, PeerCommand, PeerEvent, PeerHandle, - SpawnPeer, + accept, accept_utp_plaintext, connect, connect_prefer_utp, connect_utp_plaintext, + connect_with_utp_fallback, EncryptionPolicy, ExtHandshakeBuilder, KnownInfoHash, PeerCommand, + PeerEvent, PeerHandle, SpawnPeer, }; diff --git a/src-tauri/risuko-bt/src/peer/connection.rs b/src-tauri/risuko-bt/src/peer/connection.rs index 6e2e4868..0d2c406b 100644 --- a/src-tauri/risuko-bt/src/peer/connection.rs +++ b/src-tauri/risuko-bt/src/peer/connection.rs @@ -124,37 +124,97 @@ pub async fn connect_utp_plaintext( connect_plaintext(reader, writer, addr, &spawn).await } -/// Dial a peer, preferring TCP and falling back to µTP (BEP-29) when the TCP attempt -/// fails (refused, filtered, or timed out). Many peers are reachable over only one -/// transport—notably those behind NATs/ISPs that drop inbound TCP SYNs but pass UDP—so -/// the fallback widens connectivity. With `utp = None` this is exactly [`connect`], so -/// callers without a µTP socket keep the unchanged TCP path -/// -/// TCP is tried first rather than racing both at once: TCP carries our fast path today, -/// and a pure fallback avoids opening (then cancelling) a µTP connection for every peer -/// that TCP already reaches pub async fn connect_with_utp_fallback( spawn: SpawnPeer, utp: Option>, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + dial_with_transport_order(spawn, utp, false).await +} + +pub async fn connect_prefer_utp( + spawn: SpawnPeer, + utp: Option>, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + dial_with_transport_order(spawn, utp, true).await +} + +async fn dial_with_transport_order( + spawn: SpawnPeer, + utp: Option>, + prefer_utp: bool, +) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { + if matches!(spawn.encryption, EncryptionPolicy::RequireEncryption) { + tracing::debug!( + "skipping µTP dial to {} because encryption is required", + spawn.addr + ); + return connect(spawn).await; + } + let Some(utp) = utp else { return connect(spawn).await; }; let addr = spawn.addr; let utp_timeout = spawn.connect_timeout; + + let try_utp = |spawn: SpawnPeer, utp: std::sync::Arc| async move { + if matches!(spawn.encryption, EncryptionPolicy::RequireEncryption) { + tracing::debug!("skipping µTP dial to {addr} because encryption is required"); + return connect(spawn).await; + } + + match utp.connect_timeout(addr, utp_timeout).await { + Ok(stream) => connect_utp_plaintext(stream, spawn).await, + Err(e) => { + tracing::debug!("µTP dial to {addr} failed: {e}"); + Err(e) + } + } + }; + + if prefer_utp { + match try_utp(spawn.clone(), utp.clone()).await { + Ok(v) => { + tracing::debug!("connected to {addr} via µTP (preferred)"); + return Ok(v); + } + Err(utp_err) => { + if alternate_dial_cannot_help(&utp_err) { + return Err(utp_err); + } + tracing::debug!("µTP-first to {addr} failed ({utp_err}); trying TCP"); + return connect(spawn).await; + } + } + } + match connect(spawn.clone()).await { Ok(v) => Ok(v), - Err(tcp_err) => match utp.connect_timeout(addr, utp_timeout).await { - Ok(stream) => { - tracing::debug!("tcp dial to {addr} failed ({tcp_err}); connected via µTP"); - connect_utp_plaintext(stream, spawn).await + Err(tcp_err) => { + if alternate_dial_cannot_help(&tcp_err) { + return Err(tcp_err); + } + match try_utp(spawn, utp).await { + Ok(v) => { + tracing::debug!("tcp dial to {addr} failed ({tcp_err}); connected via µTP"); + Ok(v) + } + Err(utp_err) => { + tracing::debug!( + "tcp+µTP dial to {addr} both failed (tcp={tcp_err}, utp={utp_err})" + ); + Err(tcp_err) + } } - // Surface the TCP error — it's usually the more actionable one - Err(_) => Err(tcp_err), - }, + } } } +fn alternate_dial_cannot_help(err: &std::io::Error) -> bool { + let message = err.to_string(); + message == "info hash mismatch" || message.starts_with("self-connection ") +} + /// Accept an inbound peer connection: peer sends handshake first, we reply /// `known_hashes` is the list of info-hashes the responder currently hosts; /// it is used both to validate plaintext handshakes and to resolve the @@ -324,20 +384,14 @@ async fn drive_handshake( let (reader, writer) = stream.into_split(); connect_plaintext(reader, writer, addr, &spawn).await } - EncryptionPolicy::RequireEncryption => connect_mse(stream, addr, &spawn).await, + EncryptionPolicy::RequireEncryption => { + timeout(spawn.connect_timeout, connect_mse(stream, addr, &spawn)) + .await + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "mse handshake timeout") + })? + } EncryptionPolicy::Prefer => { - // Try plaintext first: it's a single 68-byte exchange and - // succeeds for the overwhelming majority of public-tracker - // peers. MSE-first wasted a full connect_timeout on every - // plaintext-only peer because the failed handshake leaves us - // unable to reuse the socket; we'd have to redial for the - // fallback. With plaintext-first we only redial for the rare - // ISP-blocked case - // - // Bound the plaintext attempt by connect_timeout so a peer that - // accepts the TCP connect but never sends the handshake cannot - // tie us up for the much longer read_timeout before we fall - // back to MSE let (reader, writer) = stream.into_split(); let plaintext = timeout( spawn.connect_timeout, @@ -351,20 +405,23 @@ async fn drive_handshake( std::io::Error::new(std::io::ErrorKind::TimedOut, "plaintext handshake timeout") } }; + if alternate_dial_cannot_help(&fallback_err) { + tracing::debug!( + "plaintext handshake to {addr} failed: {fallback_err}; skipping mse" + ); + return Err(fallback_err); + } tracing::debug!("plaintext handshake to {addr} failed: {fallback_err}; trying mse"); - let stream = timeout(spawn.connect_timeout, TcpStream::connect(spawn.addr)) - .await - .map_err(|_| { - std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout") - })??; - let _ = stream.set_nodelay(true); - // Log the MSE outcome so a debug-level log captures whether the - // encrypted fallback ever succeeds. Without this, an operator - // troubleshooting "0 KB/s" only sees N "trying mse" lines and - // cannot tell whether (a) every peer also rejects MSE so no - // outbound peer ever connects, or (b) MSE works fine and the - // download is slow for some other reason - match connect_mse(stream, addr, &spawn).await { + let mse = timeout(spawn.connect_timeout, async { + let stream = TcpStream::connect(spawn.addr).await?; + let _ = stream.set_nodelay(true); + connect_mse(stream, addr, &spawn).await + }) + .await + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "mse fallback timeout") + })?; + match mse { Ok(v) => { tracing::debug!("mse handshake to {addr} succeeded"); Ok(v) @@ -464,7 +521,7 @@ async fn connect_mse( spawn: &SpawnPeer, ) -> std::io::Result<(PeerHandle, mpsc::Receiver)> { let (mut read_h, mut write_h) = stream.into_split(); - let read_timeout = spawn.read_timeout; + let hs_timeout = spawn.connect_timeout; // Step 1: A -> B: Ya || PadA (0..512 bytes) let keys = DhKeys::generate(); @@ -478,7 +535,7 @@ async fn connect_mse( // until we sync on HASH('req1', S) — which is how PadB is implicitly // delimited on our side let mut yb = [0u8; DH_LEN]; - timeout(read_timeout, read_h.read_exact(&mut yb)) + timeout(hs_timeout, read_h.read_exact(&mut yb)) .await .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "mse yb timeout"))??; let s = keys.shared_secret(&yb)?; @@ -517,22 +574,13 @@ async fn connect_mse( to_send.extend_from_slice(&payload); write_h.write_all(&to_send).await?; - // Step 4: scan B's reply for encrypted VC. Because rc4 0.2 does not - // expose Clone, and we cannot "rewind" an RC4 stream, we instead - // materialise the first `max_scan` bytes of our decrypt keystream up - // front: XORing zero bytes through `dec_in` produces the keystream, - // which we consume in-place while scanning for any offset where - // ciphertext XOR keystream[off..off+8] == VC (all-zero), i.e. where - // ciphertext[off..off+8] == keystream[off..off+8] let max_scan = 1100usize; let mut keystream = vec![0u8; max_scan]; dec_in.apply_keystream(&mut keystream); - // dec_in has now advanced by max_scan bytes; we'll reset it below once - // we know the offset. To do that we recreate dec_in from the key let mut recv = Vec::with_capacity(max_scan); let mut chunk = [0u8; 256]; let mut found_offset: Option = None; - let deadline = tokio::time::Instant::now() + read_timeout; + let deadline = tokio::time::Instant::now() + hs_timeout; while recv.len() < max_scan { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { @@ -1017,8 +1065,20 @@ fn finish_spawn( }) .map_err(|e| std::io::Error::other(format!("{e}")))?; - tokio::spawn(reader_task(reader, event_tx)); - tokio::spawn(writer_task(writer, cmd_rx)); + let reader_events = event_tx.clone(); + tokio::spawn(async move { + let reason = { + let reader = reader_task(reader, reader_events); + let writer = writer_task(writer, cmd_rx); + tokio::pin!(reader); + tokio::pin!(writer); + tokio::select! { + reason = &mut reader => reason, + reason = &mut writer => reason, + } + }; + let _ = event_tx.send(PeerEvent::Disconnected { reason }).await; + }); Ok((PeerHandle { addr, tx: cmd_tx }, event_rx)) } @@ -1152,7 +1212,10 @@ impl AsyncWrite for Rc4WriteHalf { } } -async fn reader_task(mut reader: Box, tx: mpsc::Sender) { +async fn reader_task( + mut reader: Box, + tx: mpsc::Sender, +) -> String { // Larger temp buffer => fewer read syscalls per Piece message. Each // Piece reply is up to 16 KiB of payload + 13 B header; 64 KiB lets us // ingest several pipelined replies per syscall @@ -1163,17 +1226,12 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se match MessageDecoder::try_decode(&mut buf) { Ok(Some(msg)) => { if tx.send(PeerEvent::Message(msg)).await.is_err() { - return; + return "event receiver closed".into(); } } Ok(None) => break, Err(e) => { - let _ = tx - .send(PeerEvent::Disconnected { - reason: format!("decode: {e}"), - }) - .await; - return; + return format!("decode: {e}"); } } } @@ -1181,21 +1239,11 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se buf.reserve(64 * 1024); match reader.read_buf(&mut buf).await { Ok(0) => { - let _ = tx - .send(PeerEvent::Disconnected { - reason: "eof".into(), - }) - .await; - return; + return "eof".into(); } Ok(_) => {} Err(e) => { - let _ = tx - .send(PeerEvent::Disconnected { - reason: format!("io: {e}"), - }) - .await; - return; + return format!("io: {e}"); } } } @@ -1204,7 +1252,7 @@ async fn reader_task(mut reader: Box, tx: mpsc::Se async fn writer_task( mut writer: Box, mut rx: mpsc::Receiver, -) { +) -> String { // Coalesce all commands currently queued into a single write_all so a // burst of 128 pipelined Request frames becomes one syscall instead of // 128. Per-message write_all + write_all overhead was a major @@ -1234,21 +1282,26 @@ async fn writer_task( Err(_) => break, } } - if !batch.is_empty() && writer.write_all(&batch).await.is_err() { - return; + if !batch.is_empty() { + if let Err(e) = writer.write_all(&batch).await { + return format!("write: {e}"); + } } if disconnect { - let _ = writer.shutdown().await; - return; + return "local disconnect".into(); } } - let _ = writer.shutdown().await; + "command channel closed".into() } #[cfg(test)] mod tests { use super::*; use crate::wire::Message; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; use tokio::net::TcpListener; async fn run_pair() -> ( @@ -1293,6 +1346,65 @@ mod tests { (handle_a, rx_a, handle_b, rx_b) } + #[test] + fn alternate_dials_are_only_suppressed_for_identity_failures() { + for (kind, message) in [ + (std::io::ErrorKind::ConnectionReset, "reset"), + (std::io::ErrorKind::UnexpectedEof, "eof"), + (std::io::ErrorKind::BrokenPipe, "broken pipe"), + (std::io::ErrorKind::NotConnected, "not connected"), + (std::io::ErrorKind::TimedOut, "timeout"), + (std::io::ErrorKind::InvalidData, "invalid handshake length"), + ] { + let err = std::io::Error::new(kind, message); + assert!( + !alternate_dial_cannot_help(&err), + "{kind:?} / {message} should allow an alternate dial" + ); + } + + assert!(alternate_dial_cannot_help(&std::io::Error::new( + std::io::ErrorKind::InvalidData, + "info hash mismatch", + ))); + assert!(alternate_dial_cannot_help(&std::io::Error::new( + std::io::ErrorKind::InvalidData, + "self-connection (peer_id matches ours)", + ))); + } + + #[tokio::test] + async fn local_disconnect_promptly_closes_actor_and_emits_once() { + let (local, mut local_rx, remote, mut remote_rx) = run_pair().await; + assert!(matches!( + local_rx.recv().await, + Some(PeerEvent::Handshook { .. }) + )); + assert!(matches!( + remote_rx.recv().await, + Some(PeerEvent::Handshook { .. }) + )); + + let _remote = remote; + local.tx.send(PeerCommand::Disconnect).await.unwrap(); + + let event = tokio::time::timeout(Duration::from_millis(500), local_rx.recv()) + .await + .expect("local disconnect event timed out") + .expect("event channel closed before Disconnected"); + assert!(matches!( + event, + PeerEvent::Disconnected { ref reason } if reason == "local disconnect" + )); + assert!( + tokio::time::timeout(Duration::from_millis(500), local_rx.recv()) + .await + .expect("event channel did not close after Disconnected") + .is_none(), + "peer actor emitted more than one terminal event" + ); + } + #[tokio::test] async fn handshake_and_message() { let (a, mut rx_a, b, mut rx_b) = run_pair().await; @@ -1421,6 +1533,76 @@ mod tests { } } + #[tokio::test] + async fn prefer_utp_skips_utp_when_encryption_required() { + use crate::utp::UtpSocket; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_utp = UtpSocket::bind(addr).await.unwrap(); + let client_utp = UtpSocket::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let info_hash = Id20([7u8; 20]); + let peer_a = Id20([8u8; 20]); + let peer_b = Id20([9u8; 20]); + let utp_seen = Arc::new(AtomicBool::new(false)); + + let utp_seen_task = utp_seen.clone(); + let utp_watch = tokio::spawn(async move { + if let Ok(Ok(_stream)) = + tokio::time::timeout(Duration::from_secs(1), server_utp.accept()).await + { + utp_seen_task.store(true, Ordering::SeqCst); + } + }); + + let accept_fut = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + accept( + stream, + peer_b, + vec![info_hash.into()], + Duration::from_secs(10), + EncryptionPolicy::RequireEncryption, + ) + .await + .unwrap() + }); + + let (_client, mut rx_client) = connect_prefer_utp( + SpawnPeer { + addr, + info_hash, + our_peer_id: peer_a, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(10), + encryption: EncryptionPolicy::RequireEncryption, + advertise_v2: true, + ext_handshake_builder: None, + }, + Some(client_utp), + ) + .await + .unwrap(); + let (_server, mut rx_server) = accept_fut.await.unwrap(); + + match rx_client.recv().await.unwrap() { + PeerEvent::Handshook { encrypted, .. } => assert!(encrypted), + e => panic!("unexpected event: {e:?}"), + } + match rx_server.recv().await.unwrap() { + PeerEvent::Handshook { encrypted, .. } => assert!(encrypted), + e => panic!("unexpected event: {e:?}"), + } + + utp_watch.await.unwrap(); + assert!( + !utp_seen.load(Ordering::SeqCst), + "µTP should not be attempted when encryption is required" + ); + } + #[tokio::test] async fn utp_plaintext_handshake_and_message_over_loopback() { use crate::utp::UtpSocket; diff --git a/src-tauri/risuko-bt/src/piece/piece_tracker.rs b/src-tauri/risuko-bt/src/piece/piece_tracker.rs index 174a2aaf..64d6d0ba 100644 --- a/src-tauri/risuko-bt/src/piece/piece_tracker.rs +++ b/src-tauri/risuko-bt/src/piece/piece_tracker.rs @@ -127,6 +127,32 @@ impl PieceTracker { } } + pub fn update_peer_have(&mut self, peer_bitfield: &mut [u8], idx: ValidPieceIndex) -> bool { + let piece = idx.get_usize(); + let byte = piece / 8; + let bit = 7 - (piece % 8); + let Some(slot) = peer_bitfield.get_mut(byte) else { + return false; + }; + let mask = 1 << bit; + if *slot & mask != 0 { + return false; + } + + *slot |= mask; + self.availability[piece] = self.availability[piece].saturating_add(1); + self.sorted_dirty = true; + true + } + + pub fn replace_peer_bitfield(&mut self, peer_bitfield: &mut [u8], replacement: &[u8]) { + self.remove_peer_bitfield(peer_bitfield); + peer_bitfield.fill(0); + let copied = peer_bitfield.len().min(replacement.len()); + peer_bitfield[..copied].copy_from_slice(&replacement[..copied]); + self.add_peer_bitfield(peer_bitfield); + } + pub fn note_peer_has(&mut self, idx: ValidPieceIndex) { let slot = &mut self.availability[idx.get_usize()]; *slot = slot.saturating_add(1); @@ -307,6 +333,41 @@ mod tests { assert_eq!(bf[1], 0b1000_0000); } + #[test] + fn repeated_have_updates_availability_once() { + let mut tracker = PieceTracker::new(lengths(4)); + let mut peer = vec![0u8; 1]; + let piece = tracker.lengths.validate_piece(1).unwrap(); + + assert!(tracker.update_peer_have(&mut peer, piece)); + assert!(!tracker.update_peer_have(&mut peer, piece)); + assert_eq!(peer, vec![0b0100_0000]); + assert_eq!(tracker.availability, vec![0, 1, 0, 0]); + + tracker.remove_peer_bitfield(&peer); + assert_eq!(tracker.availability, vec![0, 0, 0, 0]); + } + + #[test] + fn replacing_bitfield_removes_old_counts_and_clears_short_tail() { + let mut tracker = PieceTracker::new(lengths(10)); + let mut peer = vec![0u8; 2]; + + tracker.replace_peer_bitfield(&mut peer, &[0b1000_0000, 0b1000_0000]); + assert_eq!(tracker.availability[0], 1); + assert_eq!(tracker.availability[8], 1); + + tracker.replace_peer_bitfield(&mut peer, &[0b0100_0000]); + assert_eq!(peer, vec![0b0100_0000, 0]); + assert_eq!(tracker.availability[0], 0); + assert_eq!(tracker.availability[1], 1); + assert_eq!(tracker.availability[8], 0); + + // Repeating the same replacement remains one peer of availability. + tracker.replace_peer_bitfield(&mut peer, &[0b0100_0000]); + assert_eq!(tracker.availability[1], 1); + } + #[test] fn rarest_first_choice() { let mut t = PieceTracker::new(lengths(4)); diff --git a/src-tauri/risuko-bt/src/session.rs b/src-tauri/risuko-bt/src/session.rs index 971725c5..a2c0aaef 100644 --- a/src-tauri/risuko-bt/src/session.rs +++ b/src-tauri/risuko-bt/src/session.rs @@ -17,19 +17,10 @@ use super::core::{generate_peer_id, Id20, Lengths}; use super::peer::{KnownInfoHash, PeerCommand, PeerEvent}; use super::torrent::{spawn as spawn_torrent, ManagedTorrent, TorrentCommand, TorrentInit}; -/// Read-only snapshot of the UPnP forwarder state for diagnostics #[derive(Debug, Clone, Copy)] pub struct UpnpStatus { - /// True if the forwarder was started (config enabled) at session - /// creation. False means UPnP is disabled by config pub enabled: bool, - /// Number of mappings the router has currently confirmed. Zero is - /// possible while the first discovery pass is in flight pub mapping_count: usize, - /// Number of completed discover/map passes. Zero means the forwarder - /// hasn't finished its first attempt yet (still negotiating); >= 1 - /// with `mapping_count == 0` means no IGD responded — the router - /// likely doesn't speak UPnP or has it blocked pub discovery_attempts: usize, } @@ -37,12 +28,7 @@ pub struct UpnpStatus { pub struct ListenerOptions { pub listen_addr: Option, pub enable_upnp_port_forwarding: bool, - /// Optional override for UPnP mapping lease (default 300s when UPnP is - /// enabled). Only consulted if `enable_upnp_port_forwarding` is true pub upnp_lease: Option, - /// Also bind an IPv6 TCP listener on the same port. Opt-in because many - /// hosts lack global v6 connectivity; a failed v6 bind is logged and - /// ignored rather than aborting session startup pub listen_ipv6: bool, } @@ -50,20 +36,10 @@ pub struct ListenerOptions { pub struct SessionOptions { pub disable_dht: bool, pub listen: Option, - /// Maximum concurrent chunk requests per peer. Higher values improve - /// throughput on high-latency links at the cost of more memory per peer - /// `None` uses the crate default (128) pub max_outstanding_requests_per_peer: Option, - /// Maximum simultaneous peer connections per torrent - /// `None` uses the crate default (100) pub max_peers_per_torrent: Option, pub upload_rate_limit: Option, - /// Disable BEP-14 Local Service Discovery. Defaults to enabled; some - /// hosts (CI runners, strict corporate networks) cannot bind the - /// multicast group and will warn-and-continue regardless pub disable_local_service_discovery: bool, - /// BEP-8 Message Stream Encryption (MSE/PE) policy for peer connections. - /// Defaults to `Prefer`, which tries MSE first with plaintext fallback pub encryption: super::peer::EncryptionPolicy, } @@ -73,11 +49,8 @@ pub struct AddTorrentOptions { pub trackers: Option>, pub only_files: Option>, pub list_only: bool, - /// When `true` (default), multi-file torrents are placed inside a - /// subfolder named after the torrent (`//...`). When - /// `false`, files are written directly under the output folder - /// Single-file torrents are unaffected pub create_subfolder: bool, + pub initial_peers: Vec, } impl Default for AddTorrentOptions { @@ -88,6 +61,7 @@ impl Default for AddTorrentOptions { only_files: None, list_only: false, create_subfolder: true, + initial_peers: Vec::new(), } } } @@ -113,9 +87,6 @@ pub struct Session { opts: SessionOptions, peer_id: Id20, listen_port: u16, - /// Shared µTP (BEP-29) endpoint, bound on the same UDP port as the TCP - /// listener. Threaded into every torrent so outbound dials can retry over - /// µTP when TCP fails. `None` when µTP could not bind (TCP-only fallback). utp: Option>, upload_limiter: Option>, inner: Mutex, @@ -179,25 +150,6 @@ impl Session { let local_port = listener.local_addr()?.port(); tracing::info!("session listening on port {local_port}"); - let mut upnp_handle: Option = None; - if opts - .listen - .as_ref() - .map(|l| l.enable_upnp_port_forwarding) - .unwrap_or(false) - { - let lease = opts - .listen - .as_ref() - .and_then(|l| l.upnp_lease) - .unwrap_or(std::time::Duration::from_secs(300)); - let fwd = super::upnp::UpnpPortForwarder::new( - vec![(local_port, super::upnp::MapProto::Tcp)], - lease, - ); - upnp_handle = Some(fwd.spawn()); - } - // µTP (BEP-29) endpoint: bind UDP on the same port as the TCP listener so // peers reach us at the same ip:port over either transport // Outbound dials retry over µTP when TCP fails. A bind failure is non-fatal— @@ -223,6 +175,26 @@ impl Session { } }; + let upnp_handle = if opts + .listen + .as_ref() + .map(|l| l.enable_upnp_port_forwarding) + .unwrap_or(false) + { + let lease = opts + .listen + .as_ref() + .and_then(|l| l.upnp_lease) + .unwrap_or(std::time::Duration::from_secs(300)); + let mappings = upnp_mapping_specs( + local_port, + utp.as_ref().map(|socket| socket.local_addr().port()), + ); + Some(super::upnp::UpnpPortForwarder::new(mappings, lease).spawn()) + } else { + None + }; + let upload_limiter = opts .upload_rate_limit .map(|r| Arc::new(super::limiter::UploadLimiter::new(r))); @@ -368,6 +340,10 @@ impl Session { self.listen_port } + pub fn utp_socket(&self) -> Option> { + self.utp.clone() + } + /// True if Local Service Discovery is currently spawned and running pub fn lsd_active(&self) -> bool { self.lsd.lock().is_some() @@ -416,11 +392,13 @@ impl Session { } AddTorrent::Url(url) => { let extra_trackers = opts.trackers.clone().unwrap_or_default(); - let resolved = super::magnet::resolve( + let resolved = super::magnet::resolve_with_port_and_utp( &url, &extra_trackers, + self.listen_port, std::time::Duration::from_secs(120), self.opts.encryption, + self.utp_socket(), ) .await?; let torrent_bytes = super::magnet::synth_torrent_bytes( @@ -430,6 +408,12 @@ impl Session { ); let meta = parse_torrent(&torrent_bytes) .map_err(|e| format!("parse synthesized torrent: {e}"))?; + let mut opts = opts; + if opts.initial_peers.is_empty() { + opts.initial_peers = resolved.peers; + } else { + opts.initial_peers.extend(resolved.peers); + } self.add_from_meta(meta, opts).await } } @@ -477,7 +461,21 @@ impl Session { .map_err(|e| format!("bad lengths: {e}"))?; let mut meta = meta; if let Some(extra) = opts.trackers { - meta.announce_list.push(extra); + let mut expanded = Vec::new(); + for raw in extra { + for part in raw + .split([',', '\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + if !expanded.iter().any(|x: &String| x == part) { + expanded.push(part.to_string()); + } + } + } + if !expanded.is_empty() { + meta.announce_list.push(expanded); + } } // Reserve an id and claim the info-hash atomically so a concurrent // add_from_meta cannot race past the duplicate check above. If spawn @@ -545,15 +543,28 @@ impl Session { let mut inner = self.inner.lock(); inner.torrents.insert(id, handle.clone()); } + if !opts.initial_peers.is_empty() { + let cmd_tx = handle.cmd_tx(); + let peers = opts.initial_peers; + tracing::info!( + "Seeding torrent id={id} with {} peers from magnet resolve", + peers.len() + ); + tokio::spawn(async move { + for addr in peers { + if cmd_tx + .send(super::torrent::TorrentCommand::AddPeer(addr)) + .await + .is_err() + { + break; + } + } + }); + } if let Some(lsd) = self.lsd.lock().as_ref() { lsd.add_infohash(meta.info_hash); } - // Wire DHT peer discovery into the running torrent. The session DHT is - // otherwise only used during one-shot magnet resolution; running downloads - // discover peers via trackers, LSD, and inbound connections. For a - // trackerless torrent—or one with dead trackers, which is common for CN - // Thunder/Xunlei swarms—this leaves the download with no peer source so it - // stalls at 0% even though the swarm is reachable over DHT if !info.private { if let Some(dht) = self.dht.lock().clone() { let info_hash = meta.info_hash; @@ -721,6 +732,14 @@ impl Session { } } +fn upnp_mapping_specs(tcp_port: u16, utp_port: Option) -> Vec<(u16, super::upnp::MapProto)> { + let mut mappings = vec![(tcp_port, crate::upnp::MapProto::Tcp)]; + if let Some(utp_port) = utp_port { + mappings.push((utp_port, crate::upnp::MapProto::Udp)); + } + mappings +} + /// Bind a TCP listener on an IPv6 address with `IPV6_V6ONLY` set to avoid /// dual-stack conflicts when an IPv4 listener already bound the same port fn bind_v6_listener(addr: SocketAddr) -> std::io::Result { @@ -833,3 +852,23 @@ async fn run_utp_accept_loop(utp: Arc, weak: std::sync::W }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upnp_specs_map_tcp_and_actual_utp_ports() { + assert_eq!( + upnp_mapping_specs(41_000, Some(42_000)), + vec![ + (41_000, crate::upnp::MapProto::Tcp), + (42_000, crate::upnp::MapProto::Udp), + ] + ); + assert_eq!( + upnp_mapping_specs(41_000, None), + vec![(41_000, crate::upnp::MapProto::Tcp)] + ); + } +} diff --git a/src-tauri/risuko-bt/src/storage.rs b/src-tauri/risuko-bt/src/storage.rs index d943f9ae..6b2f72a4 100644 --- a/src-tauri/risuko-bt/src/storage.rs +++ b/src-tauri/risuko-bt/src/storage.rs @@ -66,7 +66,7 @@ impl FilesystemStorage { .await .unwrap_or(false) } - + pub async fn write_at_owned(&self, offset: u64, buf: bytes::Bytes) -> Result<(), StorageError> { let total = self.layout.total_length(); let end = offset diff --git a/src-tauri/risuko-bt/src/torrent.rs b/src-tauri/risuko-bt/src/torrent.rs index 1546e703..9a54e092 100644 --- a/src-tauri/risuko-bt/src/torrent.rs +++ b/src-tauri/risuko-bt/src/torrent.rs @@ -2,7 +2,7 @@ pub mod stats; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; use arc_swap::ArcSwapOption; use bytes::Bytes; use parking_lot::Mutex; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use tokio::time::{interval, MissedTickBehavior}; use super::core::{supports_v2_wire, Id20, Lengths, MerkleProofTable, PieceVerifier, TorrentMeta}; @@ -32,10 +32,24 @@ pub use stats::{ }; const DEFAULT_MAX_OUTSTANDING_PER_PEER: usize = 6; -const UB_MAX_OUTSTANDING_PER_PEER: usize = 256; +const DEFAULT_ADAPTIVE_MAX_OUTSTANDING_PER_PEER: usize = 96; +const ABSOLUTE_MAX_OUTSTANDING_PER_PEER: usize = 256; +const TORRENT_REQUEST_BUDGET: usize = 1024; const DEFAULT_MAX_PEERS: usize = 100; -const MAX_PENDING_DIALS: usize = 256; +const MAX_PENDING_DIALS: usize = 48; +const PRIORITY_DIAL_RESERVE: usize = 12; +const MAX_PEER_BACKLOG: usize = 1024; +const DIAL_RETRY_DELAY: Duration = Duration::from_secs(20); +const USEFUL_PEER_REDIAL_DELAY: Duration = Duration::from_secs(8); +const MAX_DIAL_RETRIES: usize = 512; +const OUTBOUND_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const USEFUL_PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(12); const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); +const TRACKER_ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(30); +const TRACKER_STOPPED_TIMEOUT: Duration = Duration::from_secs(5); +const TRACKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(8); +const TRACKER_MIN_INTERVAL: Duration = Duration::from_secs(60); +const TRACKER_MAX_INTERVAL: Duration = Duration::from_secs(30 * 60); const PEER_IDLE_TIMEOUT: Duration = Duration::from_secs(180); @@ -47,6 +61,8 @@ const CHOKE_EVAL_INTERVAL: Duration = Duration::from_secs(10); const OPTIMISTIC_ROTATE_INTERVAL: Duration = Duration::from_secs(30); const PIPELINE_TARGET_SECS: f32 = 4.0; const PIPELINE_RATE_WINDOW: Duration = Duration::from_secs(2); +const PIPELINE_SLOW_START_CAP: usize = 64; +const PIPELINE_ADDITIVE_GROWTH: usize = 8; const PEX_INTERVAL: Duration = Duration::from_secs(60); const MAX_PEX_ADDED_PER_MSG: usize = 50; @@ -207,20 +223,27 @@ struct Peer { max_outstanding: usize, delivered_window: u32, window_start: Instant, + delivered_since_growth: usize, + slow_start: bool, snubbing: bool, last_recv: Instant, snub_since: Option, + consecutive_rejects: u32, + reqq_cap: Option, their_ut_metadata_id: Option, their_ut_holepunch_id: Option, downloaded_window: u64, uploaded_window: Arc, + downloaded_total: u64, their_ut_pex_id: Option, pex_sent: HashSet, outbound: bool, supports_fast: bool, } +const REJECT_SNUB_THRESHOLD: u32 = 8; + struct VerifyResult { piece_index: u32, write_failed: bool, @@ -232,9 +255,6 @@ struct PieceAssembly { received_chunks: HashSet, received_bytes: u32, expected_bytes: u32, - /// Set once the piece has been handed off to the verify/write task. - /// Late-arriving duplicates after this point must not recreate or - /// mutate the assembly completed: bool, } @@ -251,27 +271,14 @@ async fn torrent_loop( ) { let info = Arc::new(init.meta.info.clone()); let info_hash = init.meta.info_hash; - // Raw bytes of the BEP-3 / BEP-52 `info` dict. Wrapped in `Arc` so - // every per-peer ut_metadata response can borrow read-only without - // cloning the (potentially large) byte buffer let info_bytes: Arc> = Arc::new(init.meta.info_bytes.clone()); let lengths = init.lengths; let encryption = init.encryption; - // Shared µTP endpoint (if any), handed to every outbound dial so a failed - // TCP connect can retry over µTP let utp = init.utp.clone(); let upload_limiter = init.upload_limiter.clone(); let dht = init.dht.clone(); - // Preliminary: whether the meta supports v2 wire. Refined below to - // `supports_v2_wire && hash_tables.is_some()` after table construction - // so a failed build never leads us to announce truncated v2 hashes - // or answer BEP-52 HASH_REQUEST messages without valid Merkle data let supports_v2 = supports_v2_wire(&init.meta); let verifier = init.verifier; - // V2 Merkle tables for serving HASH_REQUEST — built from the meta for - // any torrent that carries v2 data (pure-v2 or hybrid). Hybrid torrents - // use V1Sha1 for piece verification but must still be able to serve - // BEP-52 hash requests to v2 peers let hash_tables: Option>> = { if let PieceVerifier::V2Merkle { ref tables, .. } = verifier { Some(Arc::clone(tables)) @@ -316,21 +323,17 @@ async fn torrent_loop( let serve_v2_layers = supports_v2 && hash_tables.is_some(); let advertise_v2 = init.advertise_v2 && serve_v2_layers; advertise_v2_flag.store(advertise_v2, Ordering::Relaxed); - let (pipeline_floor, pipeline_cap) = match init.max_outstanding_per_peer { - Some(n) => { - let n = n.max(1); - (n, n) - } - None => ( - DEFAULT_MAX_OUTSTANDING_PER_PEER, - UB_MAX_OUTSTANDING_PER_PEER, - ), - }; + + let (pipeline_floor, pipeline_cap) = pipeline_bounds(init.max_outstanding_per_peer); let max_peers = init.max_peers.unwrap_or(DEFAULT_MAX_PEERS).max(1); tracing::info!( target: "diag", - "torrent pipeline config: max_outstanding_per_peer={:?} -> floor={} cap={} max_peers={}", - init.max_outstanding_per_peer, pipeline_floor, pipeline_cap, max_peers + "torrent pipeline config: max_outstanding_per_peer={:?} -> floor={} cap={} request_budget={} max_peers={}", + init.max_outstanding_per_peer, + pipeline_floor, + pipeline_cap, + TORRENT_REQUEST_BUDGET, + max_peers ); let storage = Arc::new(FilesystemStorage::new(&info, &init.root_dir)); let mut piece_tracker = PieceTracker::new(lengths); @@ -349,17 +352,11 @@ async fn torrent_loop( s.finished = piece_tracker.is_complete(); } - // Truncated v2 hashes are only meaningful on trackers when we can - // actually serve v2 piece layers; otherwise advertise the v1 info-hash - // alone so peers we discover go through the v1 download path let announce_hashes = if serve_v2_layers { init.meta.announce_infohashes() } else { vec![info_hash] }; - // Unified peer-source channel: trackers and inbound PEX (ut_pex) both - // feed discovered peer addresses here; the main loop drains it and dials - // (with dedup + cap enforcement). DHT feeds peers via the AddPeer command let (peer_src_tx, mut peer_addr_rx) = mpsc::channel::(256); let mut tracker_tasks = spawn_tracker_pollers( peer_src_tx.clone(), @@ -370,24 +367,20 @@ async fn torrent_loop( Arc::clone(&stats), ); - // Large enough to not block peers: with MAX_PEERS peers each potentially - // delivering MAX_OUTSTANDING_PER_PEER Piece events in rapid succession, - // undersizing this channel serializes the entire download let (peer_event_tx, mut peer_event_rx) = mpsc::channel::<(u32, PeerEvent)>(8192); - // Piece hash results come back asynchronously so the main loop never - // blocks on SHA1 verification. Without this, a single 4 MB piece hash - // costs ~10 ms of CPU during which no peer event can be serviced - let (verify_tx, mut verify_rx) = mpsc::channel::(256); + let mut peers: HashMap = HashMap::new(); let mut next_pid: u32 = 1; let mut known_addrs: HashSet = HashSet::new(); // BEP-55 let mut pex_source: HashMap = HashMap::new(); let mut holepunch_attempted: HashSet = HashSet::new(); - // Outbound dials that have been spawned but whose peer has not completed - // the BT handshake yet. Tracked separately so the max-peer cap accounts - // for in-flight connection bursts, not just handshook peers let mut pending_dials: HashMap = HashMap::new(); + let mut peer_backlog: VecDeque = VecDeque::new(); + let mut priority_backlog: VecDeque = VecDeque::new(); + let mut dial_retries: VecDeque<(SocketAddr, Instant)> = VecDeque::new(); + let mut useful_redials: VecDeque<(SocketAddr, Instant)> = VecDeque::new(); + let mut useful_peers: HashMap = HashMap::new(); let registry_scope = Arc::new(()); let mut paused = false; let mut tick = interval(Duration::from_millis(500)); @@ -409,47 +402,97 @@ async fn torrent_loop( // cancel) them. Without this, a Stop racing with a piece-completion // write_at could leave a partially-written piece on disk while the // torrent loop has already returned - let mut write_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + let mut write_tasks: tokio::task::JoinSet = tokio::task::JoinSet::new(); let mut outbound_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); - loop { + let stop_ack = 'torrent: loop { tokio::select! { - Some(cmd) = cmd_rx.recv() => match cmd { + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { + tracing::debug!("torrent {torrent_id} command channel closed; shutting down"); + break 'torrent None; + }; + match cmd { TorrentCommand::AddPeer(addr) => { - if !paused - && peers.len() < max_peers - && pending_dials.len() < MAX_PENDING_DIALS - && known_addrs.insert(addr) - { - let pid = next_pid; next_pid += 1; - pending_dials.insert(pid, addr); - outbound_tasks.spawn(run_outbound_peer( + if enqueue_peer_candidate( + addr, + useful_peers.contains_key(&addr), + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + ) && !paused { + drain_peer_backlog( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut pending_dials, + &mut known_addrs, + &useful_peers, + &mut next_pid, + peers.len(), + max_peers, torrent_id, - pid, - addr, - registry_scope.clone(), + ®istry_scope, info_hash, our_peer_id, - peer_event_tx.clone(), + &peer_event_tx, encryption, advertise_v2, - Some(ext_handshake_builder.clone()), - utp.clone(), - )); + &ext_handshake_builder, + &utp, + &mut outbound_tasks, + ); } } TorrentCommand::AddInboundPeer { addr, cmd_tx, event_rx, reserved } => { if !paused && peers.len() < max_peers - && known_addrs.insert(addr) + && !known_addrs.contains(&addr) + && super::magnet::is_dialable_peer_addr(addr) { + known_addrs.insert(addr); let pid = next_pid; next_pid += 1; - adopt_inbound_peer(pid, addr, cmd_tx, event_rx, peer_event_tx.clone(), &mut peers, &lengths, &mut piece_tracker, pipeline_floor, reserved).await; + adopt_inbound_peer( + pid, + addr, + cmd_tx, + event_rx, + peer_event_tx.clone(), + &mut peers, + &lengths, + &mut piece_tracker, + pipeline_floor, + pipeline_cap, + reserved, + ) + .await; + } else { + enqueue_peer_candidate( + addr, + useful_peers.contains_key(&addr), + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + ); + let _ = cmd_tx.send(PeerCommand::Disconnect).await; } } TorrentCommand::Pause(ack) => { paused = true; + let mut paused_candidates = Vec::with_capacity(peers.len() + pending_dials.len()); for (pid, p) in peers.drain() { + if p.downloaded_total > 0 { + useful_peers.insert( + p.addr, + p.max_outstanding.clamp(pipeline_floor, pipeline_cap), + ); + } + paused_candidates.push(p.addr); let _ = p.cmd_tx.send(PeerCommand::Disconnect).await; release_peer_scheduler_state( pid, @@ -460,14 +503,58 @@ async fn torrent_loop( &lengths, ); } - pending_dials.clear(); - known_addrs.clear(); + + paused_candidates.extend(pending_dials.drain().map(|(_, addr)| addr)); + outbound_tasks.shutdown().await; + for (_, cmd_tx, registry_addr) in + peer_registry::drain_scope(torrent_id, ®istry_scope) + { + paused_candidates.push(registry_addr); + let _ = cmd_tx.try_send(PeerCommand::Disconnect); + } + + for addr in paused_candidates { + if useful_peers.contains_key(&addr) { + priority_backlog.push_back(addr); + } else { + peer_backlog.push_back(addr); + } + } + refresh_peer_queue_state( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &peers, + &pending_dials, + &mut known_addrs, + ); pex_source.clear(); holepunch_attempted.clear(); - // Wait for in-flight piece write tasks before closing handles + { + let mut s = stats.lock(); + s.live_stats.snapshot.peer_stats.live = 0; + s.peers.clear(); + } while let Some(result) = write_tasks.join_next().await { - if let Err(e) = result { - tracing::warn!("write task failed during pause: {e}"); + match result { + Ok(vr) => { + process_verify_result( + vr, + &lengths, + &mut piece_tracker, + &mut chunk_tracker, + &mut peers, + &storage, + &stats, + &mut piece_assemblies, + ) + .await; + } + Err(e) if !e.is_cancelled() => { + tracing::warn!("piece write/verify task failed during pause: {e}"); + } + Err(_) => {} } } // Release the cached file descriptors @@ -478,60 +565,66 @@ async fn torrent_loop( } TorrentCommand::Unpause(ack) => { paused = false; + // Resume immediately rather than waiting up to one tick + drain_peer_backlog( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut pending_dials, + &mut known_addrs, + &useful_peers, + &mut next_pid, + peers.len(), + max_peers, + torrent_id, + ®istry_scope, + info_hash, + our_peer_id, + &peer_event_tx, + encryption, + advertise_v2, + &ext_handshake_builder, + &utp, + &mut outbound_tasks, + ); let _ = ack.send(()); } - TorrentCommand::Stop(ack) => { - for (_, p) in peers.drain() { - let _ = p.cmd_tx.send(PeerCommand::Disconnect).await; - } - tracker_tasks.abort_all(); - while let Some(result) = tracker_tasks.join_next().await { - if let Err(e) = result { - if !e.is_cancelled() { - tracing::warn!("tracker task failed during stop: {e}"); - } - } - } - outbound_tasks.shutdown().await; - for pid in pending_dials.keys().copied().collect::>() { - peer_registry::remove(torrent_id, pid, ®istry_scope); - } - pending_dials.clear(); - // Wait for in-flight write/verify tasks before Stop acknowledges disk completion - while let Some(result) = write_tasks.join_next().await { - if let Err(e) = result { - tracing::warn!("write task failed during stop: {e}"); - } - } - // Flush and release cached file descriptors - if let Err(e) = storage.close_handles().await { - tracing::warn!("failed to close storage handles on stop: {e}"); - } - let _ = ack.send(()); - break; + TorrentCommand::Stop(ack) => break 'torrent Some(ack), } }, Some(addr) = peer_addr_rx.recv() => { - if !paused - && peers.len() < max_peers - && pending_dials.len() < MAX_PENDING_DIALS - && known_addrs.insert(addr) - { - let pid = next_pid; next_pid += 1; - pending_dials.insert(pid, addr); - outbound_tasks.spawn(run_outbound_peer( + if enqueue_peer_candidate( + addr, + useful_peers.contains_key(&addr), + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + ) && !paused { + drain_peer_backlog( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut pending_dials, + &mut known_addrs, + &useful_peers, + &mut next_pid, + peers.len(), + max_peers, torrent_id, - pid, - addr, - registry_scope.clone(), + ®istry_scope, info_hash, our_peer_id, - peer_event_tx.clone(), + &peer_event_tx, encryption, advertise_v2, - Some(ext_handshake_builder.clone()), - utp.clone(), - )); + &ext_handshake_builder, + &utp, + &mut outbound_tasks, + ); } } Some((pid, ev)) = peer_event_rx.recv() => { @@ -542,9 +635,13 @@ async fn torrent_loop( &upload_tick, &mut write_tasks, &mut pending_dials, &mut known_addrs, + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut useful_peers, &mut pex_source, &mut holepunch_attempted, &peer_src_tx, - &verify_tx, &verifier, &info_bytes, hash_tables.as_deref().map(|v| &**v), @@ -559,16 +656,22 @@ async fn torrent_loop( drive_peer(pid, &mut peers, &mut piece_tracker, &mut chunk_tracker).await; } } - Some(vr) = verify_rx.recv() => { - process_verify_result( - vr, &lengths, &mut piece_tracker, - &mut chunk_tracker, &mut peers, &storage, &stats, - &mut piece_assemblies, - ).await; - // New work may be available; re-drive all peers immediately - // instead of waiting for the next 500 ms tick - if !paused { - drive_requests(&mut peers, &mut piece_tracker, &mut chunk_tracker).await; + result = write_tasks.join_next(), if !write_tasks.is_empty() => { + match result { + Some(Ok(vr)) => { + process_verify_result( + vr, &lengths, &mut piece_tracker, + &mut chunk_tracker, &mut peers, &storage, &stats, + &mut piece_assemblies, + ).await; + if !paused { + drive_requests(&mut peers, &mut piece_tracker, &mut chunk_tracker).await; + } + } + Some(Err(e)) if !e.is_cancelled() => { + tracing::warn!("piece write/verify task failed: {e}"); + } + Some(Err(_)) | None => {} } } result = outbound_tasks.join_next(), if !outbound_tasks.is_empty() => { @@ -577,6 +680,30 @@ async fn torrent_loop( tracing::warn!("outbound peer task failed: {e}"); } } + if !paused { + drain_peer_backlog( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut pending_dials, + &mut known_addrs, + &useful_peers, + &mut next_pid, + peers.len(), + max_peers, + torrent_id, + ®istry_scope, + info_hash, + our_peer_id, + &peer_event_tx, + encryption, + advertise_v2, + &ext_handshake_builder, + &utp, + &mut outbound_tasks, + ); + } } _ = tick.tick() => { let now = Instant::now(); @@ -588,12 +715,8 @@ async fn torrent_loop( let _ = p.cmd_tx.try_send(PeerCommand::Send(Message::KeepAlive)); } } - // Reclaim chunk requests whose peer has been silent past - // the request timeout. Without this, slow-but-TCP-alive - // peers progressively hoard pieces (see REQUEST_TIMEOUT - // docs); the symptom is downloads decaying over time - // even while peer count stays constant let reclaimed = chunk_tracker.reclaim_stale(REQUEST_TIMEOUT); + let mut reclaimed_requests = 0usize; if !reclaimed.is_empty() { let mut unblocked_pieces: HashSet = HashSet::new(); for r in &reclaimed { @@ -601,51 +724,29 @@ async fn torrent_loop( if !p.snubbing { p.snubbing = true; p.snub_since = Some(Instant::now()); - p.max_outstanding = - shrink_pipeline(p.max_outstanding, pipeline_floor); + let peer_cap = p.reqq_cap.unwrap_or(pipeline_cap).min(pipeline_cap); + let peer_floor = pipeline_floor.min(peer_cap); + p.max_outstanding = shrink_pipeline(p.max_outstanding, peer_floor); p.delivered_window = 0; p.window_start = Instant::now(); + p.delivered_since_growth = 0; + p.slow_start = false; } } unblocked_pieces.insert(r.piece); - // Free the peer's outstanding slot so drive_peer - // can pipeline a different chunk. Without this, - // the slot stays consumed until the peer either - // delivers the (now reclaimed) chunk or trips - // the 120 s read timeout. - // - // Iterate every peer rather than only `r.peer`: - // in endgame mode the same chunk can be - // outstanding on multiple peers, but - // `ReclaimedChunk::peer` only carries the most - // recent `Requested { peer, .. }` writer. - // Skipping the others would permanently pin - // their request slots for p in peers.values_mut() { + let before = p.outstanding.len(); p.outstanding .retain(|&(pi, be, _)| !(pi == r.piece && be == r.begin)); + reclaimed_requests += before - p.outstanding.len(); } } - // A piece whose chunk got reclaimed may have been - // marked in-flight by drive_peer the last time it - // had no Missing chunks. Now that we made chunks - // Missing again, clear the in-flight flag so - // choose_requestable_piece returns it for pi in unblocked_pieces { if let Ok(vpi) = lengths.validate_piece(pi) { piece_tracker.clear_in_flight(vpi); } } } - // Evict peers gone silent or snubbed too long. Without this - // sweep their slot is held until the (currently absent) - // reader-side timeout fires — i.e. never — so peers leak - // permanently and `peers.len()` saturates at `max_peers`, - // blocking fresh tracker / DHT addresses. This is the dominant - // cause of "download speed is great at first then collapses to - // 0 after a while": each silent / snubbed peer parks a slot, - // and the reclaim/snub mechanism alone can't free it because - // the peer never delivers anything to clear `snubbing` { let mut to_evict: Vec = Vec::new(); for (&pid, p) in peers.iter() { @@ -659,13 +760,25 @@ async fn torrent_loop( } } } + let mut evicted_any = false; for pid in to_evict { if let Some(p) = peers.remove(&pid) { - // Free the peer's address so the tracker / - // DHT can re-dial it later. Drop bitfield - // contributions and chunk reservations the - // same way a Disconnected event would - known_addrs.remove(&p.addr); + evicted_any = true; + if p.downloaded_total > 0 { + useful_peers.insert( + p.addr, + p.max_outstanding.clamp(pipeline_floor, pipeline_cap), + ); + } + let known_useful = useful_peers.contains_key(&p.addr); + schedule_peer_retry( + p.addr, + known_useful, + &mut dial_retries, + &mut useful_redials, + ); + pex_source.retain(|_, relay| *relay != pid); + holepunch_attempted.remove(&p.addr); let _ = p.cmd_tx.try_send(PeerCommand::Disconnect); release_peer_scheduler_state( pid, @@ -675,8 +788,20 @@ async fn torrent_loop( &mut piece_assemblies, &lengths, ); + choke_dirty = true; } } + if evicted_any { + refresh_peer_queue_state( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &peers, + &pending_dials, + &mut known_addrs, + ); + } } if choke_dirty || now.duration_since(last_choke_eval) >= CHOKE_EVAL_INTERVAL { let scheduled = now.duration_since(last_window_reset) >= CHOKE_EVAL_INTERVAL; @@ -772,31 +897,140 @@ async fn torrent_loop( s.peers = peer_snaps; } } + let active_request_peers = peers + .values() + .filter(|peer| request_peer_eligible(peer)) + .count(); + let outstanding_requests: usize = + peers.values().map(|peer| peer.outstanding.len()).sum(); + let snubbed_peers = peers.values().filter(|peer| peer.snubbing).count(); + let max_pipeline = peers + .values() + .map(|peer| peer.max_outstanding) + .max() + .unwrap_or(0); + let max_effective_pipeline = peers + .values() + .filter(|peer| request_peer_eligible(peer)) + .map(|peer| { + effective_request_limit(peer.max_outstanding, active_request_peers) + }) + .max() + .unwrap_or(0); tracing::debug!( target: "diag", - "TICK summary peers={} pending_dials={} known={} endgame={} dl_bytes_tick={} ul_bytes_tick={} pending_chunks={} dt_ms={:.0}", + "TICK summary peers={} pending_dials={} known={} backlog={} endgame={} dl_bytes_tick={} ul_bytes_tick={} pending_chunks={} outstanding_requests={} active_request_peers={} snubbed_peers={} max_pipeline={} max_effective_pipeline={} reclaimed_requests={} request_budget={} dt_ms={:.0}", peers.len(), pending_dials.len(), known_addrs.len(), + peer_backlog.len(), chunk_tracker.endgame(), bytes_this_tick.0, bytes_this_tick.1, chunk_tracker.pending_chunks(), + outstanding_requests, + active_request_peers, + snubbed_peers, + max_pipeline, + max_effective_pipeline, + reclaimed_requests, + TORRENT_REQUEST_BUDGET, f64::from(dt) * 1000.0 ); bytes_this_tick = (0, 0); if !paused { + drain_peer_backlog( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut pending_dials, + &mut known_addrs, + &useful_peers, + &mut next_pid, + peers.len(), + max_peers, + torrent_id, + ®istry_scope, + info_hash, + our_peer_id, + &peer_event_tx, + encryption, + advertise_v2, + &ext_handshake_builder, + &utp, + &mut outbound_tasks, + ); drive_requests(&mut peers, &mut piece_tracker, &mut chunk_tracker).await; } } } + }; + + for (pid, peer) in peers.drain() { + let _ = peer.cmd_tx.try_send(PeerCommand::Disconnect); + release_peer_scheduler_state( + pid, + &peer.bitfield, + &mut piece_tracker, + &mut chunk_tracker, + &mut piece_assemblies, + &lengths, + ); + } + + outbound_tasks.shutdown().await; + for (_, cmd_tx, _) in peer_registry::drain_scope(torrent_id, ®istry_scope) { + let _ = cmd_tx.try_send(PeerCommand::Disconnect); + } + pending_dials.clear(); + + tracker_tasks.shutdown().await; + + priority_backlog.clear(); + peer_backlog.clear(); + dial_retries.clear(); + useful_redials.clear(); + useful_peers.clear(); + known_addrs.clear(); + pex_source.clear(); + holepunch_attempted.clear(); + + while let Some(result) = write_tasks.join_next().await { + match result { + Ok(vr) => { + process_verify_result( + vr, + &lengths, + &mut piece_tracker, + &mut chunk_tracker, + &mut peers, + &storage, + &stats, + &mut piece_assemblies, + ) + .await; + } + Err(e) if !e.is_cancelled() => { + tracing::warn!("piece write/verify task failed during shutdown: {e}"); + } + Err(_) => {} + } + } + + if let Err(e) = storage.close_handles().await { + tracing::warn!("failed to close storage handles on shutdown: {e}"); + } + { + let mut stats = stats.lock(); + stats.live_stats.snapshot.peer_stats.live = 0; + stats.peers.clear(); + } + if let Some(ack) = stop_ack { + let _ = ack.send(()); } } -/// Side-channel registry to pass outbound peer cmd_tx handles from the spawn -/// task into the main loop on first `Handshook`. Keyed by `(torrent_id, pid)` -/// because `pid` is only unique within a single torrent loop; a global -/// `pid` keying would collide across torrents mod peer_registry { use super::*; use std::sync::LazyLock; @@ -854,6 +1088,23 @@ mod peer_registry { reg.remove(&key); } } + + pub fn drain_scope( + torrent_id: usize, + scope: &Arc<()>, + ) -> Vec<(u32, mpsc::Sender, SocketAddr)> { + let mut reg = REG.lock().unwrap(); + let keys = reg + .iter() + .filter_map(|(&(entry_torrent_id, pid), entry)| { + (entry_torrent_id == torrent_id && Arc::ptr_eq(&entry.scope, scope)) + .then_some((entry_torrent_id, pid)) + }) + .collect::>(); + keys.into_iter() + .filter_map(|key| reg.remove(&key).map(|entry| (key.1, entry.tx, entry.addr))) + .collect() + } } #[allow(clippy::too_many_arguments)] @@ -869,18 +1120,28 @@ async fn run_outbound_peer( advertise_v2: bool, ext_handshake_builder: Option, utp: Option>, + known_useful: bool, ) { let spawn = SpawnPeer { addr, info_hash, our_peer_id, - connect_timeout: Duration::from_secs(10), + connect_timeout: if known_useful { + USEFUL_PEER_CONNECT_TIMEOUT + } else { + OUTBOUND_CONNECT_TIMEOUT + }, + // Post-handshake piece IO; MSE/handshake phases use connect_timeout. read_timeout: Duration::from_secs(120), encryption, advertise_v2, ext_handshake_builder, }; - match connect_with_utp_fallback(spawn, utp).await { + if known_useful { + tracing::debug!("redialing useful peer {addr} TCP-first with µTP fallback"); + } + let connect_result = connect_with_utp_fallback(spawn, utp).await; + match connect_result { Ok((handle, mut rx)) => { peer_registry::put( torrent_id, @@ -925,6 +1186,7 @@ async fn adopt_inbound_peer( lengths: &Lengths, piece_tracker: &mut PieceTracker, pipeline_floor: usize, + pipeline_cap: usize, reserved: [u8; 8], ) { let supports_fast = fast_bit(&reserved); @@ -942,13 +1204,18 @@ async fn adopt_inbound_peer( max_outstanding: pipeline_floor, delivered_window: 0, window_start: Instant::now(), + delivered_since_growth: 0, + slow_start: pipeline_floor < pipeline_cap.min(PIPELINE_SLOW_START_CAP), snubbing: false, last_recv: Instant::now(), snub_since: None, + consecutive_rejects: 0, + reqq_cap: None, their_ut_metadata_id: None, their_ut_holepunch_id: None, downloaded_window: 0, uploaded_window: Arc::new(AtomicU64::new(0)), + downloaded_total: 0, their_ut_pex_id: None, pex_sent: HashSet::new(), outbound: false, @@ -986,15 +1253,19 @@ async fn process_peer_event( stats: &Arc>, bytes_this_tick: &mut (u64, u64), upload_tick: &Arc, - write_tasks: &mut tokio::task::JoinSet<()>, + write_tasks: &mut tokio::task::JoinSet, pending_dials: &mut HashMap, known_addrs: &mut HashSet, + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + useful_peers: &mut HashMap, // BEP-55: addr gossiped via PEX -> the relay pid that gossiped it pex_source: &mut HashMap, // BEP-55: targets we've already asked a relay to rendezvous holepunch_attempted: &mut HashSet, peer_src_tx: &mpsc::Sender, - verify_tx: &mpsc::Sender, verifier: &PieceVerifier, info_bytes: &Arc>, hash_tables: Option<&[MerkleProofTable]>, @@ -1005,9 +1276,23 @@ async fn process_peer_event( upload_limiter: &Option>, dht: Option<&Arc>, ) -> bool { - // Return value: `true` if the caller should immediately kick the peer - // request pipeline. Set for events that can free an outstanding slot - // (Piece) or unblock requests (Unchoke, Bitfield, Have) + if paused { + match ev { + PeerEvent::Handshook { .. } => { + pending_dials.remove(&pid); + if let Some((cmd_tx, _)) = peer_registry::take(torrent_id, pid, registry_scope) { + let _ = cmd_tx.send(PeerCommand::Disconnect).await; + } + } + PeerEvent::Disconnected { .. } => { + pending_dials.remove(&pid); + peer_registry::remove(torrent_id, pid, registry_scope); + } + PeerEvent::Message(_) => {} + } + return false; + } + let mut kick = false; match ev { PeerEvent::Handshook { @@ -1019,31 +1304,37 @@ async fn process_peer_event( if let Some((cmd_tx, registry_addr)) = peer_registry::take(torrent_id, pid, registry_scope) { - // The torrent paused while this dial was in flight - // The take() removed the registry entry; drop `cmd_tx` and disconnect - if paused { - pending_dials.remove(&pid); - let _ = cmd_tx.send(PeerCommand::Disconnect).await; - return false; - } - // Move from pending dial to live peer. The registry is - // the authoritative source of `addr` because - // `pending_dials` may have been cleared by Pause/Stop - // while the connect+handshake was in flight; the - // registry entry is only ever written by the spawn - // task that actually completed the TCP connect let addr = pending_dials.remove(&pid).unwrap_or(registry_addr); - // Pending dials can outrun the max_peers cap (we allow - // up to MAX_PENDING_DIALS in flight). If we'd overflow, - // reject this freshly-handshook peer rather than - // exceeding the cap. Drop the address from - // known_addrs so it remains a candidate for future - // attempts when a slot frees + if peers.len() >= max_peers { - known_addrs.remove(&addr); + let known_useful = useful_peers.contains_key(&addr); + schedule_peer_retry(addr, known_useful, dial_retries, useful_redials); let _ = cmd_tx.send(PeerCommand::Disconnect).await; + refresh_peer_queue_state( + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + peers, + pending_dials, + known_addrs, + ); + tracing::debug!( + "peer {addr} handshook after cap {max_peers} filled; scheduled retry" + ); return false; } + let initial_window = useful_peers + .get(&addr) + .copied() + .unwrap_or(pipeline_floor) + .clamp(pipeline_floor, pipeline_cap); + if initial_window > pipeline_floor { + tracing::debug!( + target: "diag", + "pipeline RESUME pid={pid} addr={addr} {pipeline_floor}->{initial_window}" + ); + } tracing::debug!( "peer connected: {addr} (encrypted={encrypted}, peers={}/{max_peers})", peers.len() + 1 @@ -1060,16 +1351,21 @@ async fn process_peer_event( peer_choking: true, peer_interested: false, outstanding: Vec::new(), - max_outstanding: pipeline_floor, + max_outstanding: initial_window, delivered_window: 0, window_start: Instant::now(), + delivered_since_growth: 0, + slow_start: initial_window < pipeline_cap.min(PIPELINE_SLOW_START_CAP), snubbing: false, last_recv: Instant::now(), snub_since: None, + consecutive_rejects: 0, + reqq_cap: None, their_ut_metadata_id: None, their_ut_holepunch_id: None, downloaded_window: 0, uploaded_window: Arc::new(AtomicU64::new(0)), + downloaded_total: 0, their_ut_pex_id: None, pex_sent: HashSet::new(), outbound: true, @@ -1093,35 +1389,42 @@ async fn process_peer_event( let Some(peer) = peers.get_mut(&pid) else { return false; }; - // Refresh per-peer liveness on every wire message (including - // KeepAlive / Choke / Have, not just Piece). The torrent loop's - // tick uses this to evict peers idle past PEER_IDLE_TIMEOUT - // and recycle their slot to a fresh dial \u2014 see the eviction - // sweep in the `tick.tick()` arm peer.last_recv = Instant::now(); - if tracing::enabled!(target: "diag", tracing::Level::DEBUG) { - let kind = match &msg { - Message::Have { piece_index } => format!("Have({piece_index})"), - Message::Bitfield(b) => { - let set: u32 = b.iter().map(|x| x.count_ones()).sum(); - format!("Bitfield(len={} set_bits={})", b.len(), set) - } - Message::Piece { index, begin, data } => { - format!("Piece(i={index} b={begin} len={})", data.len()) - } - Message::Unknown { id, payload } => { - format!("Unknown(id={id} len={})", payload.len()) - } - other => format!("{other:?}"), - }; - tracing::debug!( + match &msg { + Message::Piece { index, begin, data } => tracing::trace!( target: "diag", - "RX {} {kind} am_interested={} peer_choking={} am_choking={}", - peer.addr, peer.am_interested, peer.peer_choking, peer.am_choking - ); + "RX {} Piece(i={index} b={begin} len={}) am_interested={} peer_choking={} am_choking={}", + peer.addr, + data.len(), + peer.am_interested, + peer.peer_choking, + peer.am_choking + ), + _ if tracing::enabled!(target: "diag", tracing::Level::DEBUG) => { + let kind = match &msg { + Message::Have { piece_index } => format!("Have({piece_index})"), + Message::Bitfield(b) => { + let set: u32 = b.iter().map(|x| x.count_ones()).sum(); + format!("Bitfield(len={} set_bits={})", b.len(), set) + } + Message::Unknown { id, payload } => { + format!("Unknown(id={id} len={})", payload.len()) + } + other => format!("{other:?}"), + }; + tracing::debug!( + target: "diag", + "RX {} {kind} am_interested={} peer_choking={} am_choking={}", + peer.addr, peer.am_interested, peer.peer_choking, peer.am_choking + ); + } + _ => {} } match msg { - Message::Choke => peer.peer_choking = true, + Message::Choke => { + peer.peer_choking = true; + peer.delivered_since_growth = 0; + } Message::Unchoke => { peer.peer_choking = false; kick = true; @@ -1132,38 +1435,25 @@ async fn process_peer_event( } Message::NotInterested => peer.peer_interested = false, Message::Have { piece_index } => { - let byte = (piece_index / 8) as usize; - let bit = 7 - (piece_index % 8) as u8; - if byte < peer.bitfield.len() { - peer.bitfield[byte] |= 1 << bit; - } if let Ok(vpi) = lengths.validate_piece(piece_index) { - piece_tracker.note_peer_has(vpi); + piece_tracker.update_peer_have(&mut peer.bitfield, vpi); } send_interested_if_useful(peer, piece_tracker).await; kick = true; } Message::Bitfield(bytes) => { - let n = bytes.len().min(peer.bitfield.len()); - peer.bitfield[..n].copy_from_slice(&bytes[..n]); - piece_tracker.add_peer_bitfield(&peer.bitfield); + piece_tracker.replace_peer_bitfield(&mut peer.bitfield, &bytes); send_interested_if_useful(peer, piece_tracker).await; kick = true; } Message::HaveAll => { - piece_tracker.remove_peer_bitfield(&peer.bitfield); - for b in peer.bitfield.iter_mut() { - *b = 0xff; - } - piece_tracker.add_peer_bitfield(&peer.bitfield); + let all = vec![0xff; peer.bitfield.len()]; + piece_tracker.replace_peer_bitfield(&mut peer.bitfield, &all); send_interested_if_useful(peer, piece_tracker).await; kick = true; } Message::HaveNone => { - piece_tracker.remove_peer_bitfield(&peer.bitfield); - for b in peer.bitfield.iter_mut() { - *b = 0; - } + piece_tracker.replace_peer_bitfield(&mut peer.bitfield, &[]); } Message::RejectRequest { index, @@ -1179,6 +1469,19 @@ async fn process_peer_event( if let Ok(vpi) = lengths.validate_piece(index) { chunk_tracker.reject_chunk(vpi, begin / super::core::CHUNK_SIZE, pid); piece_tracker.clear_in_flight(vpi); + } + peer.consecutive_rejects = peer.consecutive_rejects.saturating_add(1); + let peer_cap = peer.reqq_cap.unwrap_or(pipeline_cap).min(pipeline_cap); + let peer_floor = pipeline_floor.min(peer_cap); + peer.max_outstanding = shrink_pipeline(peer.max_outstanding, peer_floor); + peer.delivered_since_growth = 0; + peer.slow_start = false; + if peer.consecutive_rejects >= REJECT_SNUB_THRESHOLD && !peer.snubbing { + peer.snubbing = true; + peer.snub_since = Some(Instant::now()); + peer.delivered_window = 0; + peer.window_start = Instant::now(); + } else if !peer.snubbing { kick = true; } } @@ -1206,8 +1509,6 @@ async fn process_peer_event( if length > 1024 * 1024 { return false; } - // Reject requests that straddle the piece boundary so a - // malicious peer cannot trigger an out-of-bounds read let piece_len = lengths.piece_length_of(vpi) as u64; if (begin as u64).saturating_add(length as u64) > piece_len { return false; @@ -1277,26 +1578,52 @@ async fn process_peer_event( // as soon as it proves it can still deliver bytes peer.snubbing = false; peer.snub_since = None; + peer.consecutive_rejects = 0; peer.downloaded_window += data.len() as u64; + peer.downloaded_total += data.len() as u64; peer.delivered_window += 1; + peer.delivered_since_growth = peer.delivered_since_growth.saturating_add(1); + let peer_cap = peer.reqq_cap.unwrap_or(pipeline_cap).min(pipeline_cap); + let peer_floor = pipeline_floor.min(peer_cap); let window = peer.window_start.elapsed(); - if window >= PIPELINE_RATE_WINDOW { - let target = pipeline_target( - peer.delivered_window, - window, - pipeline_floor, - pipeline_cap, - ); - if target != peer.max_outstanding { + match pipeline_adjustment( + peer.slow_start, + peer.delivered_since_growth, + peer.delivered_window, + window, + peer_floor, + peer_cap, + peer.max_outstanding, + ) { + Some(PipelineAdjustment::SlowStart { target, finished }) => { tracing::debug!( target: "diag", - "pipeline TARGET pid={pid} addr={} {}->{target}", + "pipeline SLOW_START pid={pid} addr={} {}->{target}", peer.addr, peer.max_outstanding ); peer.max_outstanding = target; + peer.delivered_since_growth = 0; + if finished { + peer.slow_start = false; + peer.delivered_window = 0; + peer.window_start = Instant::now(); + } } - peer.delivered_window = 0; - peer.window_start = Instant::now(); + Some(PipelineAdjustment::Rate { target }) => { + let current = peer.max_outstanding; + if target != current { + tracing::debug!( + target: "diag", + "pipeline TARGET pid={pid} addr={} {current}->{target}", + peer.addr + ); + } + peer.max_outstanding = target; + peer.delivered_since_growth = 0; + peer.delivered_window = 0; + peer.window_start = Instant::now(); + } + None => {} } if piece_tracker.has_local(vpi) { kick = true; @@ -1320,34 +1647,14 @@ async fn process_peer_event( let begin_usz = begin as usize; let end_usz = begin_usz + data.len(); let chunk_len = data.len(); - // Drop late duplicates: in endgame mode the same chunk - // may arrive from multiple peers. Counting them again - // would inflate received_bytes past expected_bytes and - // skew progress reporting. Likewise ignore any chunk - // arriving after the assembly was handed off to the - // verify/write task let accepted = !assembly.completed && end_usz <= assembly.buf.len() && assembly.received_chunks.insert(chunk_index); if accepted { assembly.buf[begin_usz..end_usz].copy_from_slice(&data); assembly.received_bytes += chunk_len as u32; - // Only credit fresh bytes toward displayed download - // speed. Including duplicates would inflate the metric - // every time endgame's parallel requests collide bytes_this_tick.0 += chunk_len as u64; } - // Endgame parallel requests: now that we have this chunk - // (either fresh from this peer or already from someone - // else), tell every OTHER peer with the same outstanding - // request to stop sending it. Without this, redundant - // chunks keep saturating downstream and starve the - // remaining real requests near completion. - // Guard with `accepted`: a duplicate Piece arrival in - // endgame means we've already accepted this chunk before - // (and already sent Cancel for it). Re-scanning every - // peer's outstanding Vec for late duplicates is pure - // O(n_peers) waste if accepted && chunk_tracker.endgame() { cancel_outstanding(peers, pid, index, begin, chunk_len as u32); } @@ -1361,14 +1668,6 @@ async fn process_peer_event( let piece_done = chunk_tracker.mark_received(cinfo); kick = true; if piece_done { - // Piece complete: mark the assembly as completed and - // move its buffer out so duplicate chunks arriving - // after this point cannot mutate it. We keep the - // (now-empty) entry around as a sentinel so a stray - // late chunk does not recreate the assembly via - // entry().or_insert_with(...). The entry is removed - // by process_verify_result once the hash result - // lands let Some(assembly) = piece_assemblies.get_mut(&index) else { return kick; }; @@ -1388,19 +1687,10 @@ async fn process_peer_event( let buf = std::mem::take(&mut assembly.buf); let poff = lengths.piece_offset(vpi); let storage = storage.clone(); - let verify_tx = verify_tx.clone(); let verifier = verifier.clone(); write_tasks.spawn(async move { - // Wrap into Bytes for zero-copy share with both - // the verifier and the disk write. `Bytes::from(Vec)` - // does not copy let buf: bytes::Bytes = buf.into(); let bytes_for_verify = buf.clone(); - // Verify on the blocking pool in parallel with - // the disk write. Doing this here (not on the - // torrent loop) keeps the loop's select arm - // free to service peer events while N concurrent - // pieces hash in parallel let verify_handle = tokio::task::spawn_blocking(move || { verifier.verify(index, &bytes_for_verify).is_ok() }); @@ -1409,23 +1699,14 @@ async fn process_peer_event( // torrent loop so it does not mark the piece local let write_failed = storage.write_at_owned(poff, buf).await.is_err(); let verify_ok = verify_handle.await.unwrap_or(false); - let _ = verify_tx - .send(VerifyResult { - piece_index: index, - write_failed, - verify_ok, - }) - .await; + VerifyResult { + piece_index: index, + write_failed, + verify_ok, + } }); } } - // BEP 52 hash-exchange messages. We honour requests for an entire - // piece-layer at the piece base layer (the common shape used by the - // magnet resolver) by serving from the v2 verifier's `MerkleProofTable`. - // Other request shapes (sub-piece-layer leaf requests, partial ranges - // with proof_layers > 0) are answered with `HashReject`—a valid BEP 52 - // outcome that prompts the peer to fall back to v1 or another seeder. - // Inbound `Hashes` / `HashReject` we did not request are dropped Message::HashRequest { pieces_root, base_layer, @@ -1441,10 +1722,6 @@ async fn process_peer_event( length, proof_layers, ); - // try_send: a `.await` here would stall the entire torrent loop on a - // single peer's backed-up writer queue. HashReject / Hashes are - // best-effort—if the peer's command channel is full it will time out - // its own request and either retry or fall back to v1 let _ = peer.cmd_tx.try_send(PeerCommand::Send(response)); } Message::Hashes { .. } | Message::HashReject { .. } => { @@ -1463,6 +1740,22 @@ async fn process_peer_event( peer.their_ut_metadata_id = peer_ext.ut_metadata_id(); peer.their_ut_holepunch_id = peer_ext.ut_holepunch_id(); peer.their_ut_pex_id = peer_ext.ut_pex_id(); + + if let Some(reqq) = peer_ext.reqq { + let reqq_cap = (reqq as usize).max(1).min(pipeline_cap); + if reqq_cap < peer.max_outstanding { + tracing::debug!( + target: "diag", + "pipeline REQQ pid={pid} addr={} {}->{reqq_cap} (peer reqq={reqq})", + peer.addr, peer.max_outstanding + ); + peer.max_outstanding = reqq_cap; + peer.delivered_since_growth = 0; + } + peer.reqq_cap = Some(reqq_cap); + peer.slow_start = + peer.max_outstanding < reqq_cap.min(PIPELINE_SLOW_START_CAP); + } } } else if ext_id == OUR_UT_METADATA_ID { serve_ut_metadata(peer, &payload, info_bytes); @@ -1490,8 +1783,12 @@ async fn process_peer_event( from_hp_id, &from_cmd, peers, + pending_dials, + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, known_addrs, - peer_src_tx, ); } } @@ -1505,28 +1802,37 @@ async fn process_peer_event( } } PeerEvent::Disconnected { reason } => { - // Clear from either in-flight or live, and release the address for future - // retries (otherwise a single drop permanently blacklists the peer) let pending_addr = pending_dials.remove(&pid); let was_pending_dial = pending_addr.is_some(); - let addr = pending_addr.or_else(|| peers.get(&pid).map(|p| p.addr)); + let dead = peers.remove(&pid); + let addr = pending_addr.or_else(|| dead.as_ref().map(|peer| peer.addr)); + let useful_bytes = dead.as_ref().map_or(0, |peer| peer.downloaded_total); + let useful_window = dead + .as_ref() + .map_or(pipeline_floor, |peer| peer.max_outstanding); + if let Some(a) = addr { - // Log the disconnect cause at debug so the per-peer error — - // typically the MSE handshake outcome for unreachable / - // encryption-only peers — is visible to operators - // troubleshooting "0 KB/s" reports without us having to - // re-deduce it from "trying mse" lines alone tracing::debug!("peer {a} disconnected: {reason}"); - known_addrs.remove(&a); pex_source.retain(|_, relay| *relay != pid); if was_pending_dial { try_initiate_holepunch(a, pex_source, holepunch_attempted, peers); } + + if useful_bytes > 0 { + useful_peers.insert(a, useful_window.clamp(pipeline_floor, pipeline_cap)); + } + let known_useful = useful_bytes > 0 || useful_peers.contains_key(&a); + schedule_peer_retry(a, known_useful, dial_retries, useful_redials); + if known_useful { + tracing::debug!( + "scheduled useful peer {a} redial in {}s (delivered {useful_bytes} bytes)", + USEFUL_PEER_REDIAL_DELAY.as_secs() + ); + } } - // Drop the registry slot in case the peer disconnected before - // the Handshook event moved it into `peers` + peer_registry::remove(torrent_id, pid, registry_scope); - if let Some(dead) = peers.remove(&pid) { + if let Some(dead) = dead { *choke_dirty = true; release_peer_scheduler_state( pid, @@ -1537,6 +1843,15 @@ async fn process_peer_event( lengths, ); } + refresh_peer_queue_state( + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + peers, + pending_dials, + known_addrs, + ); } } kick @@ -1556,18 +1871,13 @@ async fn process_verify_result( let Ok(vpi) = lengths.validate_piece(vr.piece_index) else { return; }; - // Always clear in-flight regardless of verification outcome piece_tracker.clear_in_flight(vpi); - // Drop the (now-empty, completed=true) assembly sentinel so a future - // failed verification can reallocate a fresh assembly on retry piece_assemblies.remove(&vr.piece_index); if vr.write_failed { tracing::warn!( "piece {} disk write failed; will re-request", vr.piece_index ); - // Stop any other endgame peer still streaming chunks of this piece; - // they'd race the fresh re-request and force another reset cancel_piece_outstanding(peers, vr.piece_index); chunk_tracker.reset_piece(vpi); maybe_clear_endgame(chunk_tracker); @@ -1624,14 +1934,29 @@ fn handle_holepunch( from_hp_id: Option, from_cmd: &mpsc::Sender, peers: &HashMap, + pending_dials: &HashMap, + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, known_addrs: &mut HashSet, - peer_src_tx: &mpsc::Sender, ) { match hp.msg_type { holepunch_type::CONNECT => { - // A relay told us to connect to `hp.addr` now - known_addrs.remove(&hp.addr); - let _ = peer_src_tx.try_send(hp.addr); + let active_addrs = peers + .values() + .map(|peer| peer.addr) + .chain(pending_dials.values().copied()) + .collect::>(); + promote_holepunch_candidate( + hp.addr, + &active_addrs, + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + known_addrs, + ); } holepunch_type::RENDEZVOUS => { // We are the relay @@ -1686,6 +2011,293 @@ fn handle_holepunch( } } +fn promote_holepunch_candidate( + addr: SocketAddr, + active_addrs: &HashSet, + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + known_addrs: &mut HashSet, +) -> bool { + normalize_peer_queues( + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + active_addrs, + known_addrs, + ); + if !super::magnet::is_dialable_peer_addr(addr) || active_addrs.contains(&addr) { + return false; + } + + priority_backlog.retain(|candidate| *candidate != addr); + peer_backlog.retain(|candidate| *candidate != addr); + dial_retries.retain(|(candidate, _)| *candidate != addr); + useful_redials.retain(|(candidate, _)| *candidate != addr); + + let queued = + priority_backlog.len() + peer_backlog.len() + dial_retries.len() + useful_redials.len(); + if queued >= MAX_PEER_BACKLOG { + let evicted = peer_backlog + .pop_back() + .or_else(|| dial_retries.pop_back().map(|(candidate, _)| candidate)) + .or_else(|| useful_redials.pop_back().map(|(candidate, _)| candidate)) + .or_else(|| priority_backlog.pop_back()); + if evicted.is_none() { + return false; + } + } + + priority_backlog.push_front(addr); + normalize_peer_queues( + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + active_addrs, + known_addrs, + ); + true +} + +fn normalize_peer_queues( + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + active_addrs: &HashSet, + known_addrs: &mut HashSet, +) { + let old_priority = std::mem::take(priority_backlog); + let old_useful_redials = std::mem::take(useful_redials); + let old_dial_retries = std::mem::take(dial_retries); + let old_backlog = std::mem::take(peer_backlog); + + let mut seen = active_addrs.clone(); + let mut queued = 0usize; + let mut accept = |addr: SocketAddr| { + if queued >= MAX_PEER_BACKLOG + || !super::magnet::is_dialable_peer_addr(addr) + || !seen.insert(addr) + { + false + } else { + queued += 1; + true + } + }; + + for addr in old_priority { + if accept(addr) { + priority_backlog.push_back(addr); + } + } + for (addr, due) in old_useful_redials { + if accept(addr) { + useful_redials.push_back((addr, due)); + } + } + for (addr, due) in old_dial_retries { + if accept(addr) { + dial_retries.push_back((addr, due)); + } + } + for addr in old_backlog { + if accept(addr) { + peer_backlog.push_back(addr); + } + } + + known_addrs.clear(); + known_addrs.extend(seen); +} + +fn refresh_peer_queue_state( + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + peers: &HashMap, + pending_dials: &HashMap, + known_addrs: &mut HashSet, +) { + let active_addrs = peers + .values() + .map(|peer| peer.addr) + .chain(pending_dials.values().copied()) + .collect::>(); + normalize_peer_queues( + priority_backlog, + peer_backlog, + dial_retries, + useful_redials, + &active_addrs, + known_addrs, + ); +} + +fn enqueue_peer_candidate( + addr: SocketAddr, + priority: bool, + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + known_addrs: &mut HashSet, +) -> bool { + if !super::magnet::is_dialable_peer_addr(addr) || known_addrs.contains(&addr) { + return false; + } + + let queued = + priority_backlog.len() + peer_backlog.len() + dial_retries.len() + useful_redials.len(); + if queued >= MAX_PEER_BACKLOG { + if !priority { + return false; + } + let evicted = peer_backlog + .pop_back() + .or_else(|| dial_retries.pop_back().map(|(candidate, _)| candidate)) + .or_else(|| useful_redials.pop_back().map(|(candidate, _)| candidate)); + let Some(evicted) = evicted else { + return false; + }; + known_addrs.remove(&evicted); + } + + if priority { + priority_backlog.push_back(addr); + } else { + peer_backlog.push_back(addr); + } + known_addrs.insert(addr); + true +} + +fn schedule_peer_retry( + addr: SocketAddr, + useful: bool, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, +) { + if useful { + if useful_redials.len() < MAX_DIAL_RETRIES { + useful_redials.push_back((addr, Instant::now() + USEFUL_PEER_REDIAL_DELAY)); + } + } else if dial_retries.len() < MAX_DIAL_RETRIES { + dial_retries.push_back((addr, Instant::now() + DIAL_RETRY_DELAY)); + } +} + +fn dial_slot_available( + live_peers: usize, + pending_dials: usize, + max_peers: usize, + pending_limit: usize, +) -> bool { + live_peers.saturating_add(pending_dials) < max_peers && pending_dials < pending_limit +} + +fn drain_peer_backlog( + priority_backlog: &mut VecDeque, + peer_backlog: &mut VecDeque, + dial_retries: &mut VecDeque<(SocketAddr, Instant)>, + useful_redials: &mut VecDeque<(SocketAddr, Instant)>, + pending_dials: &mut HashMap, + known_addrs: &mut HashSet, + useful_peers: &HashMap, + next_pid: &mut u32, + live_peers: usize, + max_peers: usize, + torrent_id: usize, + registry_scope: &Arc<()>, + info_hash: Id20, + our_peer_id: Id20, + peer_event_tx: &mpsc::Sender<(u32, PeerEvent)>, + encryption: crate::peer::EncryptionPolicy, + advertise_v2: bool, + ext_handshake_builder: &crate::peer::ExtHandshakeBuilder, + utp: &Option>, + outbound_tasks: &mut tokio::task::JoinSet<()>, +) { + // Promote due useful-peer redials into the priority backlog. + let now = Instant::now(); + while useful_redials.front().is_some_and(|(_, due)| *due <= now) { + let Some((addr, _)) = useful_redials.pop_front() else { + break; + }; + if known_addrs.contains(&addr) { + priority_backlog.push_front(addr); + } + } + + while dial_retries.front().is_some_and(|(_, due)| *due <= now) { + let Some((addr, _)) = dial_retries.pop_front() else { + break; + }; + if known_addrs.contains(&addr) { + peer_backlog.push_back(addr); + } + } + + let spawn_one = |addr: SocketAddr, + pending_dials: &mut HashMap, + next_pid: &mut u32, + outbound_tasks: &mut tokio::task::JoinSet<()>| { + let pid = *next_pid; + *next_pid += 1; + pending_dials.insert(pid, addr); + let known_useful = useful_peers.contains_key(&addr); + outbound_tasks.spawn(run_outbound_peer( + torrent_id, + pid, + addr, + registry_scope.clone(), + info_hash, + our_peer_id, + peer_event_tx.clone(), + encryption, + advertise_v2, + Some(ext_handshake_builder.clone()), + utp.clone(), + known_useful, + )); + }; + + while dial_slot_available( + live_peers, + pending_dials.len(), + max_peers, + MAX_PENDING_DIALS, + ) { + let Some(addr) = priority_backlog.pop_front() else { + break; + }; + if !known_addrs.contains(&addr) { + continue; + } + spawn_one(addr, pending_dials, next_pid, outbound_tasks); + } + + // Cold backlog leaves PRIORITY_DIAL_RESERVE free when the swarm is thin + let cold_cap = if live_peers < 8 { + MAX_PENDING_DIALS.saturating_sub(PRIORITY_DIAL_RESERVE) + } else { + MAX_PENDING_DIALS + }; + while dial_slot_available(live_peers, pending_dials.len(), max_peers, cold_cap) { + let Some(addr) = peer_backlog.pop_front() else { + break; + }; + if !known_addrs.contains(&addr) { + continue; + } + spawn_one(addr, pending_dials, next_pid, outbound_tasks); + } +} + /// On a failed direct dial to `target`, ask the peer that gossiped it via /// PEX to perform a BEP-55 rendezvous fn try_initiate_holepunch( @@ -1742,9 +2354,6 @@ async fn send_interested_if_useful(peer: &mut Peer, piece_tracker: &mut PieceTra } async fn broadcast_have(peers: &mut HashMap, piece_index: u32) { - // Use try_send so a single backed-up peer can't stall the main loop; - // if the channel is full the peer will get an updated bitfield via the - // 500 ms tick anyway, and Have is best-effort for p in peers.values_mut() { let _ = p .cmd_tx @@ -1752,6 +2361,18 @@ async fn broadcast_have(peers: &mut HashMap, piece_index: u32) { } } +fn request_peer_eligible(peer: &Peer) -> bool { + !peer.peer_choking && peer.am_interested && !peer.snubbing +} + +fn effective_request_limit(adaptive_limit: usize, active_request_peers: usize) -> usize { + if active_request_peers == 0 { + return 0; + } + let fair_share = (TORRENT_REQUEST_BUDGET / active_request_peers).max(1); + adaptive_limit.min(fair_share) +} + async fn drive_requests( peers: &mut HashMap, piece_tracker: &mut PieceTracker, @@ -1763,10 +2384,74 @@ async fn drive_requests( } } -fn pipeline_target(delivered_chunks: u32, window: Duration, floor: usize, cap: usize) -> usize { +fn pipeline_bounds(configured: Option) -> (usize, usize) { + let cap = configured + .map(|value| value.max(1).min(ABSOLUTE_MAX_OUTSTANDING_PER_PEER)) + .unwrap_or(DEFAULT_ADAPTIVE_MAX_OUTSTANDING_PER_PEER); + (DEFAULT_MAX_OUTSTANDING_PER_PEER.min(cap), cap) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PipelineAdjustment { + SlowStart { target: usize, finished: bool }, + Rate { target: usize }, +} + +#[allow(clippy::too_many_arguments)] +fn pipeline_adjustment( + slow_start: bool, + delivered_since_growth: usize, + delivered_chunks: u32, + window: Duration, + floor: usize, + cap: usize, + current: usize, +) -> Option { + if slow_start { + let target = pipeline_slow_start_target(delivered_since_growth, current, cap)?; + return Some(PipelineAdjustment::SlowStart { + target, + finished: target >= cap.min(PIPELINE_SLOW_START_CAP), + }); + } + (window >= PIPELINE_RATE_WINDOW).then(|| PipelineAdjustment::Rate { + target: pipeline_target(delivered_chunks, window, floor, cap, current), + }) +} + +fn pipeline_slow_start_target( + delivered_since_growth: usize, + current: usize, + cap: usize, +) -> Option { + let probe_cap = cap.min(PIPELINE_SLOW_START_CAP); + if current >= probe_cap || delivered_since_growth < current { + return None; + } + let target = current.saturating_mul(2).min(probe_cap); + (target > current).then_some(target) +} + +fn pipeline_target( + delivered_chunks: u32, + window: Duration, + floor: usize, + cap: usize, + current: usize, +) -> usize { let secs = window.as_secs_f32().max(0.001); let rate = delivered_chunks as f32 / secs; // chunks per second - ((rate * PIPELINE_TARGET_SECS) as usize).clamp(floor, cap) + let raw = ((rate * PIPELINE_TARGET_SECS) as usize).clamp(floor, cap); + if raw > current { + let step = if current >= PIPELINE_SLOW_START_CAP { + PIPELINE_ADDITIVE_GROWTH + } else { + (current / 2).max(PIPELINE_ADDITIVE_GROWTH) + }; + current.saturating_add(step).min(raw).min(cap).max(floor) + } else { + raw + } } fn shrink_pipeline(current: usize, floor: usize) -> usize { @@ -1922,34 +2607,28 @@ async fn drive_peer( piece_tracker: &mut PieceTracker, chunk_tracker: &mut ChunkTracker, ) { + let active_request_peers = peers + .values() + .filter(|peer| request_peer_eligible(peer)) + .count(); + let outstanding_requests: usize = peers.values().map(|peer| peer.outstanding.len()).sum(); + let remaining_request_budget = TORRENT_REQUEST_BUDGET.saturating_sub(outstanding_requests); let Some(peer) = peers.get_mut(&pid) else { return; }; - if peer.peer_choking || !peer.am_interested { + if !request_peer_eligible(peer) || remaining_request_budget == 0 { return; } - // Snubbing: skip peers whose previous request timed out. - // The flag clears on the next `Piece` arrival from this peer, so this - // is a transient park, not a permanent ban. Without it, drive_peer - // would immediately refill the slot we just freed in the reclaim - // path, retriggering the timeout each REQUEST_TIMEOUT cycle and - // wasting global request budget on a stuck peer - if peer.snubbing { - return; - } - let max_outstanding = peer.max_outstanding; - // Pieces this peer already attempted in this drive_peer call but for which - // next_chunk returned None (no chunk available for this peer, even under - // endgame). Tracked so the endgame fallback below — which uses - // PieceTracker::choose_piece (does NOT skip in_flight) — does not loop - // forever picking the same piece every iteration + let max_outstanding = effective_request_limit(peer.max_outstanding, active_request_peers); + let mut request_slots = remaining_request_budget; + let mut exhausted: HashSet = HashSet::new(); let requestable_pieces = piece_tracker.choose_requestable_pieces(&peer.bitfield, pid); let mut requestable_idx = 0usize; let mut endgame_pieces: Option> = None; let mut endgame_idx = 0usize; let mut current_piece: Option = None; - while peer.outstanding.len() < max_outstanding { + while peer.outstanding.len() < max_outstanding && request_slots > 0 { let piece = match current_piece { Some(piece) => piece, None => { @@ -2006,6 +2685,7 @@ async fn drive_peer( Ok(()) => { peer.outstanding .push((info.piece_index.get(), info.offset, info.size)); + request_slots -= 1; } Err(TrySendError::Full(_)) => { // Roll the chunk back so a different peer (or this @@ -2175,19 +2855,204 @@ fn should_send_initial_bitfield(bitfield: &[u8]) -> bool { fn collect_trackers(meta: &TorrentMeta) -> Vec { let mut v = Vec::new(); + let mut push = |raw: &str| { + for part in raw + .split([',', '\n', '\r']) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + if !v.iter().any(|x| x == part) { + v.push(part.to_string()); + } + } + }; if let Some(a) = &meta.announce { - v.push(a.clone()); + push(a); } for tier in &meta.announce_list { for url in tier { - if !v.contains(url) { - v.push(url.clone()); - } + push(url); } } v } +struct TrackerPollers { + shutdown_tx: watch::Sender, + tasks: tokio::task::JoinSet<()>, +} + +impl TrackerPollers { + async fn shutdown(&mut self) { + let _ = self.shutdown_tx.send(true); + let deadline = tokio::time::Instant::now() + TRACKER_SHUTDOWN_GRACE; + + while !self.tasks.is_empty() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, self.tasks.join_next()).await { + Ok(Some(Ok(()))) => {} + Ok(Some(Err(e))) if e.is_cancelled() => {} + Ok(Some(Err(e))) => tracing::warn!("tracker task failed during stop: {e}"), + Ok(None) => return, + Err(_) => break, + } + } + + if !self.tasks.is_empty() { + tracing::warn!( + "tracker shutdown exceeded {:?}; aborting {} poller(s)", + TRACKER_SHUTDOWN_GRACE, + self.tasks.len() + ); + self.tasks.abort_all(); + while let Some(result) = self.tasks.join_next().await { + if let Err(e) = result { + if !e.is_cancelled() { + tracing::warn!("tracker task failed after abort: {e}"); + } + } + } + } + } +} + +fn clamp_tracker_interval(interval: Duration) -> Duration { + interval.clamp(TRACKER_MIN_INTERVAL, TRACKER_MAX_INTERVAL) +} + +fn tracker_retry_delay(consecutive_failures: u32) -> Duration { + const BACKOFF_SECS: [u64; 5] = [15, 30, 60, 120, 300]; + let idx = consecutive_failures.saturating_sub(1) as usize; + Duration::from_secs(BACKOFF_SECS[idx.min(BACKOFF_SECS.len() - 1)]) +} + +fn tracker_event_for_attempt( + pending: AnnounceEvent, + finished: bool, + sent_completed: bool, +) -> AnnounceEvent { + if matches!(pending, AnnounceEvent::None) && finished && !sent_completed { + AnnounceEvent::Completed + } else { + pending + } +} + +fn tracker_event_after_success(sent: AnnounceEvent, sent_completed: &mut bool) -> AnnounceEvent { + if matches!(sent, AnnounceEvent::Completed) { + *sent_completed = true; + } + AnnounceEvent::None +} + +fn tracker_request( + info_hash: Id20, + peer_id: Id20, + port: u16, + stats: &Arc>, + event: AnnounceEvent, + num_want: u32, +) -> AnnounceRequest { + let (uploaded, downloaded, left) = { + let stats = stats.lock(); + ( + stats.uploaded_bytes, + stats.progress_bytes, + stats.total_bytes.saturating_sub(stats.progress_bytes), + ) + }; + AnnounceRequest { + info_hash, + peer_id, + port, + uploaded, + downloaded, + left, + event, + num_want, + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_tracker_poller( + tx: mpsc::Sender, + url: String, + info_hash: Id20, + peer_id: Id20, + port: u16, + stats: Arc>, + mut shutdown: watch::Receiver, +) { + let mut pending_event = AnnounceEvent::Started; + let mut sent_completed = false; + let mut consecutive_failures = 0u32; + + 'poll: loop { + if *shutdown.borrow() { + break; + } + let finished = stats.lock().finished; + pending_event = tracker_event_for_attempt(pending_event, finished, sent_completed); + let event = pending_event; + let req = tracker_request(info_hash, peer_id, port, &stats, event, 200); + + let result = tokio::select! { + biased; + _ = shutdown.changed() => break 'poll, + result = tracker_announce(&url, &req, TRACKER_ANNOUNCE_TIMEOUT) => result, + }; + + let delay = match result { + Ok(resp) => { + tracing::info!( + target: "diag", + "tracker ANNOUNCE ok url={url} event={event:?} peers={} interval_s={}", + resp.peers.len(), + resp.interval.as_secs() + ); + consecutive_failures = 0; + pending_event = tracker_event_after_success(event, &mut sent_completed); + for addr in resp.peers { + let sent = tokio::select! { + biased; + _ = shutdown.changed() => break 'poll, + sent = tx.send(addr) => sent, + }; + if sent.is_err() { + break 'poll; + } + } + clamp_tracker_interval(resp.interval) + } + Err(e) => { + consecutive_failures = consecutive_failures.saturating_add(1); + let delay = tracker_retry_delay(consecutive_failures); + tracing::info!( + target: "diag", + "tracker ANNOUNCE fail url={url} event={event:?} retry_s={} err={e}", + delay.as_secs() + ); + delay + } + }; + + tokio::select! { + biased; + _ = shutdown.changed() => break, + _ = tokio::time::sleep(delay) => {} + } + } + + let stopped = tracker_request(info_hash, peer_id, port, &stats, AnnounceEvent::Stopped, 0); + match tracker_announce(&url, &stopped, TRACKER_STOPPED_TIMEOUT).await { + Ok(_) => tracing::debug!("tracker STOPPED ok url={url}"), + Err(e) => tracing::debug!("tracker STOPPED failed url={url}: {e}"), + } +} + fn spawn_tracker_pollers( tx: mpsc::Sender, trackers: Vec, @@ -2195,80 +3060,23 @@ fn spawn_tracker_pollers( peer_id: Id20, port: u16, stats: Arc>, -) -> tokio::task::JoinSet<()> { +) -> TrackerPollers { + let (shutdown_tx, shutdown_rx) = watch::channel(false); let mut tasks = tokio::task::JoinSet::new(); for url in trackers { for info_hash in &info_hashes { - let tx = tx.clone(); - let url = url.clone(); - let info_hash = *info_hash; - let stats = Arc::clone(&stats); - tasks.spawn(async move { - let mut event = AnnounceEvent::Started; - // Track whether we have already announced `Completed` to - // this tracker so we only emit it once per session, even if - // the torrent finishes mid-poll loop. Without this, - // re-announcing `Completed` every interval would inflate - // tracker-side completion counters - let mut sent_completed = false; - loop { - let (uploaded, downloaded, left, finished) = { - let s = stats.lock(); - let left = s.total_bytes.saturating_sub(s.progress_bytes); - (s.uploaded_bytes, s.progress_bytes, left, s.finished) - }; - // Promote to `Completed` the first time we observe - // `finished` after Started; trackers use this signal - // to move us into the seeders bucket and start - // returning leechers (who will actually request from - // us) instead of fellow seeders (who won't) - if finished && !sent_completed && !matches!(event, AnnounceEvent::Started) { - event = AnnounceEvent::Completed; - } - let req = AnnounceRequest { - info_hash, - peer_id, - port, - uploaded, - downloaded, - left, - event, - // Match aria2/libtorrent: ask for the maximum the BEP-3 - // tracker will return. 50 used to leave us starved with - // single-tracker torrents - num_want: 200, - }; - match tracker_announce(&url, &req, Duration::from_secs(30)).await { - Ok(resp) => { - // DIAG: per-URL peer yield — quantifies whether Risuko's - // (default-empty) tracker set is starving the swarm vs BitComet's - tracing::info!( - target: "diag", - "tracker ANNOUNCE ok url={url} event={event:?} peers={} interval_s={}", - resp.peers.len(), - resp.interval.as_secs() - ); - if matches!(event, AnnounceEvent::Completed) { - sent_completed = true; - } - for a in resp.peers { - if tx.send(a).await.is_err() { - return; - } - } - tokio::time::sleep(resp.interval).await; - } - Err(e) => { - tracing::info!(target: "diag", "tracker ANNOUNCE fail url={url} event={event:?} err={e}"); - tokio::time::sleep(Duration::from_secs(120)).await; - } - } - event = AnnounceEvent::None; - } - }); + tasks.spawn(run_tracker_poller( + tx.clone(), + url.clone(), + *info_hash, + peer_id, + port, + Arc::clone(&stats), + shutdown_rx.clone(), + )); } } - tasks + TrackerPollers { shutdown_tx, tasks } } fn release_peer_scheduler_state( @@ -2294,19 +3102,7 @@ fn release_peer_scheduler_state( } } -/// Build a `HASHES` / `HashReject` reply for an inbound BEP 52 -/// `HASH_REQUEST`. We honour requests that ask for an entire piece-layer -/// row (`base_layer == log2(piece_length / 16 KiB)`, `index == 0`, -/// `length == next_pow2(piece_count)`, `proof_layers == 0`), which is the -/// shape the magnet resolver uses. All other request shapes are answered -/// with `HashReject` — the peer can then fall back to v1 verification or -/// another seeder. This is a valid BEP 52 outcome -/// -/// `tables` is the optional slice of per-file Merkle proof tables. It is -/// `Some` for pure-v2 and hybrid torrents and `None` for pure-v1 torrents. -/// Passing the tables separately (rather than the verifier) means hybrid -/// torrents — which use `V1Sha1` for piece verification — can still serve -/// BEP-52 HASH_REQUEST to v2 peers +/// Build a `HASHES` / `HashReject` reply for an inbound BEP 52 `HASH_REQUEST` fn build_hash_response( tables: Option<&[MerkleProofTable]>, pieces_root: [u8; 32], @@ -2407,12 +3203,314 @@ mod tests { use crate::core::ValidatedTorrentMetaV1Info; use std::path::Path; + fn test_peer(port: u16) -> SocketAddr { + SocketAddr::from(([203, 0, 113, 1], port)) + } + + #[test] + fn peer_queue_normalization_deduplicates_by_precedence_and_rebuilds_known_set() { + let now = Instant::now(); + let active_a = test_peer(10_001); + let active_b = test_peer(10_002); + let priority_a = test_peer(10_003); + let priority_b = test_peer(10_004); + let useful = test_peer(10_005); + let retry = test_peer(10_006); + let cold = test_peer(10_007); + let mut active = HashSet::from([active_a, active_b]); + let mut priority_backlog = VecDeque::from([priority_a, priority_b, active_a]); + let mut useful_redials = VecDeque::from([ + (priority_b, now + Duration::from_secs(1)), + (useful, now + Duration::from_secs(2)), + ]); + let mut dial_retries = VecDeque::from([ + (useful, now + Duration::from_secs(3)), + (retry, now + Duration::from_secs(4)), + ]); + let mut peer_backlog = VecDeque::from([retry, cold, active_b]); + let mut known_addrs = HashSet::from([test_peer(19_999)]); + + normalize_peer_queues( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &active, + &mut known_addrs, + ); + + assert_eq!(priority_backlog, VecDeque::from([priority_a, priority_b])); + assert_eq!( + useful_redials + .iter() + .map(|(addr, _)| *addr) + .collect::>(), + vec![useful] + ); + assert_eq!( + dial_retries + .iter() + .map(|(addr, _)| *addr) + .collect::>(), + vec![retry] + ); + assert_eq!(peer_backlog, VecDeque::from([cold])); + active.extend([priority_a, priority_b, useful, retry, cold]); + assert_eq!(known_addrs, active); + } + + #[test] + fn peer_queue_normalization_caps_combined_unique_candidates() { + let mut priority_backlog = VecDeque::new(); + let mut peer_backlog = (0..MAX_PEER_BACKLOG + 100) + .map(|i| test_peer(20_000 + i as u16)) + .collect::>(); + let mut dial_retries = VecDeque::new(); + let mut useful_redials = VecDeque::new(); + let active = HashSet::from([test_peer(10_001)]); + let mut known_addrs = HashSet::new(); + + normalize_peer_queues( + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &active, + &mut known_addrs, + ); + + let queued = + priority_backlog.len() + peer_backlog.len() + dial_retries.len() + useful_redials.len(); + assert_eq!(queued, MAX_PEER_BACKLOG); + assert_eq!(known_addrs.len(), MAX_PEER_BACKLOG + active.len()); + assert!(active.is_subset(&known_addrs)); + } + + #[test] + fn holepunch_promotion_moves_delayed_candidate_to_priority_front() { + let now = Instant::now(); + let active = HashSet::from([test_peer(11_001)]); + let existing_priority = test_peer(11_002); + let target = test_peer(11_003); + let cold = test_peer(11_004); + let mut priority_backlog = VecDeque::from([existing_priority]); + let mut peer_backlog = VecDeque::from([cold]); + let mut dial_retries = VecDeque::from([(target, now + Duration::from_secs(30))]); + let mut useful_redials = VecDeque::from([(target, now + Duration::from_secs(10))]); + let mut known_addrs = HashSet::new(); + + assert!(promote_holepunch_candidate( + target, + &active, + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + )); + + assert_eq!( + priority_backlog, + VecDeque::from([target, existing_priority]) + ); + assert!(!peer_backlog.contains(&target)); + assert!(!dial_retries.iter().any(|(addr, _)| *addr == target)); + assert!(!useful_redials.iter().any(|(addr, _)| *addr == target)); + let expected = active + .iter() + .copied() + .chain(priority_backlog.iter().copied()) + .chain(peer_backlog.iter().copied()) + .chain(dial_retries.iter().map(|(addr, _)| *addr)) + .chain(useful_redials.iter().map(|(addr, _)| *addr)) + .collect::>(); + assert_eq!(known_addrs, expected); + } + + #[test] + fn holepunch_promotion_does_not_redial_active_or_pending_target() { + let now = Instant::now(); + let target = test_peer(12_001); + let active = HashSet::from([target, test_peer(12_002)]); + let mut priority_backlog = VecDeque::from([target, test_peer(12_003)]); + let mut peer_backlog = VecDeque::from([target, test_peer(12_004)]); + let mut dial_retries = VecDeque::from([(target, now + Duration::from_secs(30))]); + let mut useful_redials = VecDeque::from([(target, now + Duration::from_secs(10))]); + let mut known_addrs = HashSet::new(); + + assert!(!promote_holepunch_candidate( + target, + &active, + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + )); + + assert!(!priority_backlog.contains(&target)); + assert!(!peer_backlog.contains(&target)); + assert!(!dial_retries.iter().any(|(addr, _)| *addr == target)); + assert!(!useful_redials.iter().any(|(addr, _)| *addr == target)); + let expected = active + .iter() + .copied() + .chain(priority_backlog.iter().copied()) + .chain(peer_backlog.iter().copied()) + .chain(dial_retries.iter().map(|(addr, _)| *addr)) + .chain(useful_redials.iter().map(|(addr, _)| *addr)) + .collect::>(); + assert_eq!(known_addrs, expected); + } + + #[test] + fn holepunch_promotion_preserves_cap_and_evicts_cold_tail() { + let now = Instant::now(); + let existing_priority = test_peer(13_001); + let useful = test_peer(13_002); + let retry = test_peer(13_003); + let mut priority_backlog = VecDeque::from([existing_priority]); + let mut useful_redials = VecDeque::from([(useful, now + Duration::from_secs(10))]); + let mut dial_retries = VecDeque::from([(retry, now + Duration::from_secs(30))]); + let mut peer_backlog = (0..MAX_PEER_BACKLOG - 3) + .map(|i| test_peer(20_000 + i as u16)) + .collect::>(); + let evicted = *peer_backlog.back().expect("full combined queue"); + let target = test_peer(45_001); + let active = HashSet::from([test_peer(13_004)]); + let mut known_addrs = HashSet::new(); + + assert!(promote_holepunch_candidate( + target, + &active, + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + )); + + assert_eq!(priority_backlog.front(), Some(&target)); + assert!(!known_addrs.contains(&evicted)); + let queued = + priority_backlog.len() + peer_backlog.len() + dial_retries.len() + useful_redials.len(); + assert_eq!(queued, MAX_PEER_BACKLOG); + let expected = active + .iter() + .copied() + .chain(priority_backlog.iter().copied()) + .chain(peer_backlog.iter().copied()) + .chain(dial_retries.iter().map(|(addr, _)| *addr)) + .chain(useful_redials.iter().map(|(addr, _)| *addr)) + .collect::>(); + assert_eq!(known_addrs, expected); + } + + #[test] + fn priority_peer_displaces_lowest_priority_cold_candidate_when_full() { + let mut priority_backlog = VecDeque::new(); + let mut peer_backlog = (0..MAX_PEER_BACKLOG) + .map(|i| test_peer(30_000 + i as u16)) + .collect::>(); + let evicted = *peer_backlog.back().expect("full backlog"); + let mut dial_retries = VecDeque::new(); + let mut useful_redials = VecDeque::new(); + let mut known_addrs = peer_backlog.iter().copied().collect::>(); + let priority = test_peer(45_000); + + assert!(enqueue_peer_candidate( + priority, + true, + &mut priority_backlog, + &mut peer_backlog, + &mut dial_retries, + &mut useful_redials, + &mut known_addrs, + )); + + assert_eq!(priority_backlog, VecDeque::from([priority])); + assert_eq!(peer_backlog.len(), MAX_PEER_BACKLOG - 1); + assert!(known_addrs.contains(&priority)); + assert!(!known_addrs.contains(&evicted)); + assert_eq!(known_addrs.len(), MAX_PEER_BACKLOG); + } + + #[test] + fn dial_slots_count_pending_handshakes_against_peer_cap() { + assert!(!dial_slot_available(99, 1, 100, MAX_PENDING_DIALS)); + assert!(!dial_slot_available(52, 48, 100, MAX_PENDING_DIALS)); + assert!(dial_slot_available(51, 47, 100, MAX_PENDING_DIALS)); + assert!(!dial_slot_available(0, 36, 100, 36)); + } + + #[test] + fn pipeline_slow_start_requires_a_full_successful_turnover() { + assert_eq!(pipeline_slow_start_target(5, 6, 256), None); + assert_eq!(pipeline_slow_start_target(6, 6, 256), Some(12)); + } + + #[test] + fn pipeline_slow_start_doubles_and_clamps_to_probe_or_peer_cap() { + assert_eq!(pipeline_slow_start_target(12, 12, 256), Some(24)); + assert_eq!(pipeline_slow_start_target(24, 24, 256), Some(48)); + assert_eq!(pipeline_slow_start_target(48, 48, 256), Some(64)); + assert_eq!(pipeline_slow_start_target(6, 6, 10), Some(10)); + assert_eq!(pipeline_slow_start_target(64, 64, 256), None); + assert_eq!(pipeline_slow_start_target(10, 10, 10), None); + } + #[test] fn pipeline_target_tracks_delivery_rate() { - assert_eq!(pipeline_target(128, Duration::from_secs(2), 6, 256), 256); - assert_eq!(pipeline_target(2, Duration::from_secs(2), 6, 256), 6); - assert_eq!(pipeline_target(20, Duration::from_secs(2), 6, 256), 40); - assert_eq!(pipeline_target(128, Duration::from_secs(2), 32, 32), 32); + assert_eq!(pipeline_target(128, Duration::from_secs(2), 6, 256, 6), 14); + assert_eq!(pipeline_target(2, Duration::from_secs(2), 6, 256, 6), 6); + assert_eq!(pipeline_target(20, Duration::from_secs(2), 6, 256, 6), 14); + assert_eq!(pipeline_target(128, Duration::from_secs(2), 32, 32, 32), 32); + assert_eq!(pipeline_target(128, Duration::from_secs(2), 6, 256, 14), 22); + assert_eq!(pipeline_target(128, Duration::from_secs(2), 6, 256, 64), 72); + assert_eq!(pipeline_target(128, Duration::from_secs(2), 6, 256, 72), 80); + } + + #[test] + fn slow_start_and_rate_control_are_mutually_exclusive_per_piece() { + let elapsed = Duration::from_secs(2); + assert_eq!( + pipeline_adjustment(true, 6, 128, elapsed, 6, 256, 6), + Some(PipelineAdjustment::SlowStart { + target: 12, + finished: false, + }) + ); + assert_eq!( + pipeline_adjustment(false, 64, 128, elapsed, 6, 256, 64), + Some(PipelineAdjustment::Rate { target: 72 }) + ); + } + + #[test] + fn configured_outstanding_is_cap_not_floor() { + let (floor, cap) = pipeline_bounds(Some(128)); + assert_eq!((floor, cap), (6, 128)); + assert_ne!(floor, cap); + assert_eq!(shrink_pipeline(cap, floor), 64); + assert_eq!(shrink_pipeline(64, floor), 32); + } + + #[test] + fn adaptive_outstanding_uses_a_lower_default_but_keeps_explicit_hard_cap() { + assert_eq!(pipeline_bounds(None), (6, 96)); + assert_eq!(pipeline_bounds(Some(256)), (6, 256)); + assert_eq!(pipeline_bounds(Some(512)), (6, 256)); + assert_eq!(pipeline_bounds(Some(1)), (1, 1)); + } + + #[test] + fn torrent_request_budget_is_divided_fairly_across_active_peers() { + assert_eq!(effective_request_limit(256, 1), 256); + assert_eq!(effective_request_limit(256, 2), 256); + assert_eq!(effective_request_limit(256, 18), 56); + assert_eq!(effective_request_limit(256, 100), 10); + assert_eq!(effective_request_limit(96, 18), 56); + assert_eq!(effective_request_limit(96, 0), 0); } #[test] @@ -2486,6 +3584,49 @@ mod tests { (lengths, layout) } + #[test] + fn tracker_intervals_and_failures_are_bounded() { + assert_eq!( + clamp_tracker_interval(Duration::from_secs(1)), + TRACKER_MIN_INTERVAL + ); + assert_eq!( + clamp_tracker_interval(Duration::from_secs(24 * 60 * 60)), + TRACKER_MAX_INTERVAL + ); + assert_eq!(tracker_retry_delay(1), Duration::from_secs(15)); + assert_eq!(tracker_retry_delay(2), Duration::from_secs(30)); + assert_eq!(tracker_retry_delay(5), Duration::from_secs(300)); + assert_eq!(tracker_retry_delay(99), Duration::from_secs(300)); + } + + #[test] + fn tracker_started_and_completed_remain_pending_until_success() { + let mut sent_completed = false; + let mut pending = AnnounceEvent::Started; + + assert_eq!( + tracker_event_for_attempt(pending, false, sent_completed), + AnnounceEvent::Started + ); + pending = tracker_event_after_success(pending, &mut sent_completed); + assert_eq!(pending, AnnounceEvent::None); + + pending = tracker_event_for_attempt(pending, true, sent_completed); + assert_eq!(pending, AnnounceEvent::Completed); + assert_eq!( + tracker_event_for_attempt(pending, true, sent_completed), + AnnounceEvent::Completed + ); + pending = tracker_event_after_success(pending, &mut sent_completed); + assert_eq!(pending, AnnounceEvent::None); + assert!(sent_completed); + assert_eq!( + tracker_event_for_attempt(pending, true, sent_completed), + AnnounceEvent::None + ); + } + #[test] fn verified_piece_progress_is_idempotent() { let (lengths, layout) = two_file_layout(); @@ -2524,6 +3665,32 @@ mod tests { assert!(peer_registry::take(torrent_id, pid, &scope_a).is_some()); } + #[test] + fn peer_registry_drain_scope_is_isolated_by_torrent_and_generation() { + let torrent_a = 91_001; + let torrent_b = 91_002; + let scope_a = Arc::new(()); + let scope_b = Arc::new(()); + let addr_a = test_peer(51_001); + let addr_b = test_peer(51_002); + let addr_other_torrent = test_peer(51_003); + let (tx_a, _rx_a) = mpsc::channel(1); + let (tx_b, _rx_b) = mpsc::channel(1); + let (tx_other, _rx_other) = mpsc::channel(1); + + peer_registry::put(torrent_a, 1, &scope_a, tx_a, addr_a); + peer_registry::put(torrent_a, 2, &scope_b, tx_b, addr_b); + peer_registry::put(torrent_b, 1, &scope_a, tx_other, addr_other_torrent); + + let drained = peer_registry::drain_scope(torrent_a, &scope_a); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].0, 1); + assert_eq!(drained[0].2, addr_a); + assert!(peer_registry::take(torrent_a, 1, &scope_a).is_none()); + assert!(peer_registry::take(torrent_a, 2, &scope_b).is_some()); + assert!(peer_registry::take(torrent_b, 1, &scope_a).is_some()); + } + #[test] fn build_hash_response_serves_full_piece_layer() { let (tables, root) = make_v2_tables(4, 64 * 1024); diff --git a/src-tauri/risuko-bt/src/tracker/http.rs b/src-tauri/risuko-bt/src/tracker/http.rs index 8cba4a14..8b1d2eaf 100644 --- a/src-tauri/risuko-bt/src/tracker/http.rs +++ b/src-tauri/risuko-bt/src/tracker/http.rs @@ -6,10 +6,12 @@ use std::time::Duration; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; -use super::super::bencode::decode_all; +use super::super::bencode::{decode_all_external, DecodeLimits}; use super::{AnnounceRequest, AnnounceResponse, TrackerError}; /// RFC 3986 unreserved + `-_.~` are safe in a URL without percent-encoding +const TRACKER_RESPONSE_LIMITS: DecodeLimits = DecodeLimits::new(2 * 1024 * 1024, 64, 262_144); + const QUERY_SAFE: &percent_encoding::AsciiSet = &NON_ALPHANUMERIC .remove(b'-') .remove(b'.') @@ -64,7 +66,7 @@ fn build_query(req: &AnnounceRequest) -> String { } fn parse_response(bytes: &[u8]) -> Result { - let value = decode_all(bytes)?; + let value = decode_all_external(bytes, TRACKER_RESPONSE_LIMITS)?; value .as_dict() .ok_or_else(|| TrackerError::Rejected("response not a dict".into()))?; @@ -182,6 +184,15 @@ mod tests { assert_eq!(r.peers[0].port(), 6881); } + #[test] + fn accepts_unsorted_response_and_trailing_whitespace() { + let body = b"d5:peers0:8:intervali60ee\r\n"; + let response = parse_response(body).unwrap(); + + assert_eq!(response.interval, Duration::from_secs(60)); + assert!(response.peers.is_empty()); + } + #[test] fn propagates_failure_reason() { let body = encode_to_vec(&Value::Dict(vec![( diff --git a/src-tauri/risuko-bt/src/tracker/udp.rs b/src-tauri/risuko-bt/src/tracker/udp.rs index 807d7a42..702db100 100644 --- a/src-tauri/risuko-bt/src/tracker/udp.rs +++ b/src-tauri/risuko-bt/src/tracker/udp.rs @@ -7,12 +7,14 @@ //! Retransmits use the BEP-15 recommendation: n = 0..8, timeout = 15 * 2^n //! seconds. For v1 we shorten to 3 tries to fit into our async budget +use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use byteorder::{BigEndian, ByteOrder}; use rand::RngExt; use tokio::net::{lookup_host, UdpSocket}; +use tokio::task::JoinSet; use tokio::time::timeout; use super::{AnnounceRequest, AnnounceResponse, TrackerError}; @@ -24,11 +26,49 @@ const ACTION_ERROR: u32 = 3; pub async fn announce(url: &str, req: &AnnounceRequest) -> Result { let (host, port) = parse_udp_url(url)?; - let target = lookup_host((host.as_str(), port)) - .await? - .next() - .ok_or_else(|| TrackerError::Url(format!("no DNS result for {host}")))?; + let targets = dedupe_endpoints(lookup_host((host.as_str(), port)).await?); + if targets.is_empty() { + return Err(TrackerError::Url(format!("no DNS result for {host}"))); + } + + let mut attempts = JoinSet::new(); + for target in targets { + let req = req.clone(); + attempts.spawn(async move { announce_endpoint(target, &req).await }); + } + + let mut last_error = None; + while let Some(result) = attempts.join_next().await { + match result { + Ok(Ok(response)) => { + attempts.abort_all(); + return Ok(response); + } + Ok(Err(error)) => last_error = Some(error), + Err(error) => { + last_error = Some(TrackerError::Io(std::io::Error::other(format!( + "UDP tracker endpoint task failed: {error}" + )))); + } + } + } + + Err(last_error + .unwrap_or_else(|| TrackerError::Url(format!("no usable DNS endpoint for {host}")))) +} +fn dedupe_endpoints(endpoints: impl IntoIterator) -> Vec { + let mut seen = HashSet::new(); + endpoints + .into_iter() + .filter(|endpoint| seen.insert(*endpoint)) + .collect() +} + +async fn announce_endpoint( + target: SocketAddr, + req: &AnnounceRequest, +) -> Result { let sock = UdpSocket::bind(if target.is_ipv6() { "[::]:0" } else { @@ -202,6 +242,17 @@ mod tests { ); } + #[test] + fn dns_endpoints_are_deduplicated_without_reordering() { + let v4: SocketAddr = "192.0.2.10:80".parse().unwrap(); + let v6: SocketAddr = "[2001:db8::10]:80".parse().unwrap(); + let other: SocketAddr = "192.0.2.11:80".parse().unwrap(); + assert_eq!( + dedupe_endpoints([v4, v6, v4, other, v6]), + vec![v4, v6, other] + ); + } + #[test] fn rejects_wrong_scheme() { assert!(parse_udp_url("http://x:1").is_err()); diff --git a/src-tauri/risuko-bt/src/wire/extended.rs b/src-tauri/risuko-bt/src/wire/extended.rs index f6a13211..c10a60ee 100644 --- a/src-tauri/risuko-bt/src/wire/extended.rs +++ b/src-tauri/risuko-bt/src/wire/extended.rs @@ -10,10 +10,15 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use bytes::Bytes; -use super::super::bencode::{decode_all, encode_to_vec, Value}; +use super::super::bencode::{ + decode_all_external, decode_external, encode_to_vec, DecodeLimits, Value, +}; pub const EXT_HANDSHAKE_ID: u8 = 0; +const EXTENSION_DECODE_LIMITS: DecodeLimits = + DecodeLimits::new(super::MAX_MESSAGE_BYTES, 32, 65_536); + pub const EXT_NAME_UT_METADATA: &[u8] = b"ut_metadata"; pub const EXT_NAME_UT_PEX: &[u8] = b"ut_pex"; pub const EXT_NAME_UT_HOLEPUNCH: &[u8] = b"ut_holepunch"; @@ -51,16 +56,11 @@ pub mod holepunch_err { #[derive(Debug, Clone, Default)] pub struct ExtHandshake { - /// Map of extension name -> per-peer message id pub supported: HashMap, u8>, - /// Total size of the `info` dict, if the peer advertises it (BEP-9) pub metadata_size: Option, - /// Peer-advertised client string ("v" key) pub client: Option, - /// BEP-10 `yourip`: the peer's public address as we observed it. Some real-world - /// clients (notably some CN BT implementations) only engage with a remote that - /// echoes their address back here pub yourip: Option, + pub reqq: Option, } impl ExtHandshake { @@ -79,6 +79,7 @@ impl ExtHandshake { env!("CARGO_PKG_VERSION") )), yourip: None, + reqq: None, } } @@ -89,9 +90,6 @@ impl ExtHandshake { self } - /// Advertise `ut_holepunch` (BEP-55) with the message id we want peers to - /// use when sending us holepunch messages. Builder so callers can opt in - /// without churning the `new_outgoing` signature pub fn with_holepunch(mut self, ut_holepunch_id: u8) -> Self { self.supported .insert(EXT_NAME_UT_HOLEPUNCH.to_vec(), ut_holepunch_id); @@ -126,7 +124,7 @@ impl ExtHandshake { } pub fn decode(payload: &[u8]) -> Option { - let value = decode_all(payload).ok()?; + let value = decode_all_external(payload, EXTENSION_DECODE_LIMITS).ok()?; let dict = value.as_dict()?; let mut supported = HashMap::new(); if let Some((_, m)) = dict.iter().find(|(k, _)| k == b"m") { @@ -166,11 +164,17 @@ impl ExtHandshake { } _ => None, }); + let reqq = dict + .iter() + .find(|(k, _)| k == b"reqq") + .and_then(|(_, v)| v.as_int()) + .and_then(|n| if n > 0 { Some(n as u32) } else { None }); Some(Self { supported, metadata_size, client, yourip, + reqq, }) } @@ -230,7 +234,7 @@ pub struct UtMetadataMsg { pub fn parse_ut_metadata(payload: Bytes) -> Option { // ut_metadata messages carry a bencoded dict followed by the raw data for // DATA messages. We need to know how many bytes the dict consumed - let mut p = crate::bencode::decode(&payload).ok()?; + let mut p = decode_external(&payload, EXTENSION_DECODE_LIMITS).ok()?; let block = payload.slice(p.span.end..); let dict = match &mut p.value { Value::Dict(d) => std::mem::take(&mut *d), @@ -261,7 +265,7 @@ pub fn parse_ut_metadata(payload: Bytes) -> Option { pub fn parse_ut_pex( payload: &[u8], ) -> Option<(Vec, Vec)> { - let value = decode_all(payload).ok()?; + let value = decode_all_external(payload, EXTENSION_DECODE_LIMITS).ok()?; let dict = value.as_dict()?; let mut v4 = Vec::new(); let mut v6 = Vec::new(); @@ -429,6 +433,20 @@ mod tests { assert!(parsed.client.is_some()); } + #[test] + fn handshake_parses_reqq() { + let payload = encode_to_vec(&Value::Dict(vec![ + ( + b"m".to_vec(), + Value::Dict(vec![(b"ut_metadata".to_vec(), Value::Int(2))]), + ), + (b"reqq".to_vec(), Value::Int(250)), + ])); + let parsed = ExtHandshake::decode(&payload).unwrap(); + assert_eq!(parsed.reqq, Some(250)); + assert_eq!(parsed.ut_metadata_id(), Some(2)); + } + #[test] fn ut_metadata_request_parse() { let bytes = ut_metadata_request(5); diff --git a/src-tauri/risuko-engine/src/config/defaults.rs b/src-tauri/risuko-engine/src/config/defaults.rs index 98885c83..40ca19d3 100644 --- a/src-tauri/risuko-engine/src/config/defaults.rs +++ b/src-tauri/risuko-engine/src/config/defaults.rs @@ -20,7 +20,7 @@ pub fn system_defaults() -> Map { m.insert("bt-create-subfolder".into(), json!(true)); m.insert("bt-tracker".into(), json!("")); m.insert("bt-max-peers-per-torrent".into(), json!(100)); - m.insert("bt-max-outstanding-per-peer".into(), json!(128)); + m.insert("bt-max-outstanding-per-peer".into(), json!(0)); m.insert("bt-upload-rate-limit".into(), json!(0)); m.insert("bt-enable-upnp".into(), json!(true)); m.insert("bt-upnp-lease".into(), json!(300)); diff --git a/src-tauri/risuko-engine/src/engine/torrent.rs b/src-tauri/risuko-engine/src/engine/torrent.rs index 38c9150b..4cbb4bc9 100644 --- a/src-tauri/risuko-engine/src/engine/torrent.rs +++ b/src-tauri/risuko-engine/src/engine/torrent.rs @@ -1,9 +1,14 @@ +use futures_util::FutureExt; use risuko_bt as bt; use serde_json::{Map, Value}; -use std::net::Ipv4Addr; +use std::collections::HashMap; +use std::future::Future; +use std::net::{Ipv4Addr, SocketAddr}; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; +use tokio::sync::{watch, Mutex}; /// BitTorrent session tuning passed from the user/system config. All /// fields are optional; missing entries fall back to `risuko-bt` defaults @@ -32,11 +37,120 @@ pub struct BtHealthSnapshot { pub dht_nodes: usize, } +#[derive(Debug)] +struct ResolvedMagnetMeta { + bytes: Arc<[u8]>, + peers: Arc<[SocketAddr]>, +} + +struct CachedMagnetMeta { + meta: Arc, + expires_at: Instant, +} + +type SharedMagnetResult = Result, Arc>; + +#[derive(Default)] +struct MagnetMetaCacheState { + completed: HashMap<[u8; 20], CachedMagnetMeta>, + in_flight: HashMap<[u8; 20], watch::Sender>>, +} + +#[derive(Clone, Default)] +struct MagnetMetaCache { + state: Arc>, +} + +const MAGNET_META_CACHE_TTL: Duration = Duration::from_secs(120); + +impl MagnetMetaCache { + async fn get_or_resolve( + &self, + key: [u8; 20], + resolver: F, + ) -> Result, String> + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let receiver = { + let mut state = self.state.lock().await; + let now = Instant::now(); + state.completed.retain(|_, entry| entry.expires_at > now); + + if let Some(entry) = state.completed.get(&key) { + tracing::info!( + "Magnet metadata cache hit ({} peers)", + entry.meta.peers.len() + ); + return Ok(entry.meta.clone()); + } + + if let Some(sender) = state.in_flight.get(&key) { + tracing::info!("Joining in-flight magnet metadata resolution"); + sender.subscribe() + } else { + let (sender, receiver) = watch::channel(None); + state.in_flight.insert(key, sender.clone()); + + let cache = self.clone(); + tokio::spawn(async move { + let result = match AssertUnwindSafe(async move { resolver().await }) + .catch_unwind() + .await + { + Ok(result) => result.map(Arc::new).map_err(Arc::::from), + Err(_) => { + tracing::warn!("Magnet metadata resolver panicked"); + Err(Arc::::from("Magnet metadata resolver panicked")) + } + }; + + { + let mut state = cache.state.lock().await; + if let Ok(meta) = &result { + state.completed.insert( + key, + CachedMagnetMeta { + meta: meta.clone(), + expires_at: Instant::now() + MAGNET_META_CACHE_TTL, + }, + ); + } + state.in_flight.remove(&key); + } + + let _ = sender.send(Some(result)); + }); + + receiver + } + }; + + Self::await_result(receiver).await + } + + async fn await_result( + mut receiver: watch::Receiver>, + ) -> Result, String> { + loop { + if let Some(result) = receiver.borrow().clone() { + return result.map_err(|error| error.to_string()); + } + receiver + .changed() + .await + .map_err(|_| "Magnet metadata resolver stopped unexpectedly".to_string())?; + } + } +} + /// BitTorrent download management via the in-tree `risuko-bt` engine #[derive(Clone)] pub struct TorrentEngine { session: Option>, output_dir: PathBuf, + magnet_cache: MagnetMetaCache, } impl TorrentEngine { @@ -74,6 +188,7 @@ impl TorrentEngine { Ok(Self { session: Some(session), output_dir: output_dir.to_path_buf(), + magnet_cache: MagnetMetaCache::default(), }) } @@ -138,6 +253,16 @@ impl TorrentEngine { &self, data: &[u8], options: &Map, + ) -> Result { + self.add_torrent_bytes_with_peers(data, options, Vec::new()) + .await + } + + pub async fn add_torrent_bytes_with_peers( + &self, + data: &[u8], + options: &Map, + initial_peers: Vec, ) -> Result { let session = self.get_session()?; @@ -163,6 +288,7 @@ impl TorrentEngine { only_files, list_only: false, create_subfolder, + initial_peers, }; tracing::info!("Adding torrent bytes ({} bytes) to dir={}", data.len(), dir); @@ -190,28 +316,18 @@ impl TorrentEngine { options: &Map, timeout_secs: u64, ) -> Result { - let trackers = Self::parse_trackers(options); - let enc = encryption_policy_from_str( - options.get("bt-encryption-policy").and_then(|v| v.as_str()), - ); - let resolved = bt::magnet::resolve( - magnet_uri, - &trackers, - Duration::from_secs(timeout_secs), - enc, - ) - .await - .map_err(|e| format!("Failed to resolve magnet: {}", e))?; - - let bytes = bt::magnet::synth_torrent_bytes( - &resolved.info_bytes, - &resolved.trackers, - &resolved.piece_layers, - ); + let (bytes, peers) = self + .resolve_magnet_bytes(magnet_uri, options, timeout_secs) + .await?; save_torrent_metadata_if_enabled(&bytes, options, &self.output_dir).await; - self.add_torrent_bytes(&bytes, options).await + tracing::info!( + "Magnet resolved with {} discovered peers; seeding download", + peers.len() + ); + self.add_torrent_bytes_with_peers(&bytes, options, peers) + .await } pub async fn resolve_magnet( @@ -234,51 +350,106 @@ impl TorrentEngine { } } - let trackers = Self::parse_trackers(options); tracing::info!("Resolving magnet metadata: {}", magnet_uri); let start = std::time::Instant::now(); + let (torrent_bytes, _) = self + .resolve_magnet_bytes(magnet_uri, options, timeout_secs) + .await?; + let meta = bt::parse_torrent(&torrent_bytes) + .map_err(|e| format!("Failed to parse resolved metadata: {}", e))?; + let files = extract_file_details(&meta.info); + tracing::info!( + "Magnet metadata resolved in {:?} ({} files)", + start.elapsed(), + files.len() + ); + Ok(files) + } + async fn resolve_magnet_bytes( + &self, + magnet_uri: &str, + options: &Map, + timeout_secs: u64, + ) -> Result<(Vec, Vec), String> { + let info_hash_key = bt::Magnet::parse(magnet_uri) + .ok() + .map(|m| *m.info_hash().as_bytes()); + let trackers = Self::parse_trackers(options); let enc = encryption_policy_from_str( options.get("bt-encryption-policy").and_then(|v| v.as_str()), ); + let session = self.get_session()?; + let listen_port = session.listen_port(); + let utp = session.utp_socket(); + let magnet_uri = magnet_uri.to_string(); + + let resolve = move || async move { + Self::resolve_magnet_uncached(magnet_uri, trackers, listen_port, timeout_secs, enc, utp) + .await + }; + + let resolved = match info_hash_key { + Some(key) => self.magnet_cache.get_or_resolve(key, resolve).await?, + None => Arc::new(resolve().await?), + }; + + Ok((resolved.bytes.to_vec(), resolved.peers.to_vec())) + } + + async fn resolve_magnet_uncached( + magnet_uri: String, + trackers: Vec, + listen_port: u16, + timeout_secs: u64, + enc: bt::EncryptionPolicy, + utp: Option>, + ) -> Result { let resolved = tokio::time::timeout( Duration::from_secs(timeout_secs), - bt::magnet::resolve( - magnet_uri, + bt::magnet::resolve_with_port_and_utp( + &magnet_uri, &trackers, + listen_port, Duration::from_secs(timeout_secs), enc, + utp, ), ) .await .map_err(|_| "Timed out resolving magnet metadata".to_string())? .map_err(|e| format!("Failed to resolve magnet: {}", e))?; - let torrent_bytes = bt::magnet::synth_torrent_bytes( - &resolved.info_bytes, - &resolved.trackers, - &resolved.piece_layers, - ); - let meta = bt::parse_torrent(&torrent_bytes) - .map_err(|e| format!("Failed to parse resolved metadata: {}", e))?; - let files = extract_file_details(&meta.info); - tracing::info!( - "Magnet metadata resolved in {:?} ({} files)", - start.elapsed(), - files.len() - ); - Ok(files) + Ok(ResolvedMagnetMeta { + bytes: Arc::from( + bt::magnet::synth_torrent_bytes( + &resolved.info_bytes, + &resolved.trackers, + &resolved.piece_layers, + ) + .into_boxed_slice(), + ), + peers: Arc::from(resolved.peers.into_boxed_slice()), + }) } fn parse_trackers(options: &Map) -> Vec { - options + let raw = options .get("bt-tracker") .and_then(|v| v.as_str()) - .unwrap_or("") - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() + .unwrap_or(""); + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for part in raw.split([',', '\n', '\r']) { + let t = part.trim(); + if t.is_empty() { + continue; + } + if seen.insert(t.to_string()) { + out.push(t.to_string()); + } + } + out } pub fn get_torrent_stats(&self, torrent_id: usize) -> Option { @@ -555,6 +726,117 @@ fn encryption_policy_from_str(s: Option<&str>) -> bt::EncryptionPolicy { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn resolved_meta(byte: u8) -> ResolvedMagnetMeta { + ResolvedMagnetMeta { + bytes: Arc::from(vec![byte].into_boxed_slice()), + peers: Arc::from(Vec::::new().into_boxed_slice()), + } + } + + #[tokio::test] + async fn magnet_cache_singleflights_concurrent_callers() { + let cache = MagnetMetaCache::default(); + let key = [7; 20]; + let calls = Arc::new(AtomicUsize::new(0)); + + let first_calls = calls.clone(); + let first = cache.get_or_resolve(key, move || async move { + first_calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(25)).await; + Ok(resolved_meta(42)) + }); + + let second_calls = calls.clone(); + let second = cache.get_or_resolve(key, move || async move { + second_calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(25)).await; + Ok(resolved_meta(42)) + }); + + let (first, second) = tokio::join!(first, second); + assert_eq!(first.unwrap().bytes.as_ref(), &[42]); + assert_eq!(second.unwrap().bytes.as_ref(), &[42]); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let cached_calls = calls.clone(); + let cached = cache + .get_or_resolve(key, move || async move { + cached_calls.fetch_add(1, Ordering::SeqCst); + Ok(resolved_meta(99)) + }) + .await + .unwrap(); + assert_eq!(cached.bytes.as_ref(), &[42]); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn magnet_cache_failure_does_not_poison_retry() { + let cache = MagnetMetaCache::default(); + let key = [9; 20]; + + let error = cache + .get_or_resolve(key, || async { Err("first attempt failed".to_string()) }) + .await + .unwrap_err(); + assert_eq!(error, "first attempt failed"); + + let retried = cache + .get_or_resolve(key, || async { Ok(resolved_meta(11)) }) + .await + .unwrap(); + assert_eq!(retried.bytes.as_ref(), &[11]); + } + + #[tokio::test] + async fn magnet_cache_panic_does_not_leave_in_flight_entry() { + let cache = MagnetMetaCache::default(); + let key = [4; 20]; + + let error = tokio::time::timeout(Duration::from_secs(1), async { + cache + .get_or_resolve(key, || async { + panic!("resolver boom"); + #[allow(unreachable_code)] + Ok(resolved_meta(0)) + }) + .await + }) + .await + .expect("panicked resolver should not hang") + .unwrap_err(); + assert_eq!(error, "Magnet metadata resolver panicked"); + + let retried = tokio::time::timeout(Duration::from_secs(1), async { + cache + .get_or_resolve(key, || async { Ok(resolved_meta(12)) }) + .await + }) + .await + .expect("retry after resolver panic should not hang") + .unwrap(); + assert_eq!(retried.bytes.as_ref(), &[12]); + } + + #[test] + fn parse_trackers_splits_newlines_and_commas() { + let mut opts = Map::new(); + opts.insert( + "bt-tracker".into(), + json!("udp://a:1/announce\n\nudp://b:2/announce,http://c:3/announce\r\nudp://a:1/announce"), + ); + assert_eq!( + TorrentEngine::parse_trackers(&opts), + vec![ + "udp://a:1/announce".to_string(), + "udp://b:2/announce".to_string(), + "http://c:3/announce".to_string(), + ] + ); + } #[test] fn encryption_policy_known_values() { diff --git a/src-tauri/src/cli/headless.rs b/src-tauri/src/cli/headless.rs index 15c35c87..549f3ab1 100644 --- a/src-tauri/src/cli/headless.rs +++ b/src-tauri/src/cli/headless.rs @@ -9,11 +9,24 @@ use risuko_engine::engine::manager::TaskManager; use risuko_engine::engine::options::EngineOptions; use risuko_engine::engine::rpc::RpcServer; +fn init_headless_tracing() { + use tracing_subscriber::EnvFilter; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,diag=debug,risuko_bt=debug,risuko_engine=info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .with_ansi(false) + .try_init(); +} + /// Start the engine in headless mode (no Tauri, no GUI). /// Returns a handle to shut down when done pub async fn start_headless_engine( rpc_port: u16, ) -> Result> { + init_headless_tracing(); + let config_dir = get_config_dir(); std::fs::create_dir_all(&config_dir)?; diff --git a/src/renderer/components/Preference/Advanced.vue b/src/renderer/components/Preference/Advanced.vue index 674e6ea1..2354f9a9 100644 --- a/src/renderer/components/Preference/Advanced.vue +++ b/src/renderer/components/Preference/Advanced.vue @@ -386,8 +386,8 @@
@@ -1395,7 +1395,7 @@ const initForm = (config) => { externalEngineSecret: externalEngineSecret || "", btTracker: convertCommaToLine(btTracker), btMaxPeersPerTorrent: btMaxPeersPerTorrent ?? 100, - btMaxOutstandingPerPeer: btMaxOutstandingPerPeer ?? 128, + btMaxOutstandingPerPeer: btMaxOutstandingPerPeer ?? 0, btEnableUpnp: parseBooleanConfig(btEnableUpnp, true), btUpnpLease: btUpnpLease ?? 300, btEnableLsd: parseBooleanConfig(btEnableLsd, true), diff --git a/src/renderer/components/Stats/StatsPage.vue b/src/renderer/components/Stats/StatsPage.vue index e9da7492..6c246a3c 100644 --- a/src/renderer/components/Stats/StatsPage.vue +++ b/src/renderer/components/Stats/StatsPage.vue @@ -163,62 +163,106 @@
No speed samples in this range
- - - - + {{ tick.label }} + :key="tick.y" + :x1="CHART_PAD_X" + :x2="chartWidth - CHART_PAD_RIGHT" + :y1="tick.y" + :y2="tick.y" + class="speed-grid" + /> + + {{ tick.label }} + {{ tick.label }} + {{ tick.label }} - - Speed - Time - - + class="speed-axis-label speed-axis-label--y" + :transform="`translate(18 ${CHART_PAD_TOP + CHART_PLOT_HEIGHT / 2}) rotate(-90)`" + >Speed + Time + + + + +
+
{{ speedHover.label }}
+
+ + {{ point.label }} + {{ formatBytes(point.value) }}/s +
+
+
- + {{ line.label }}
@@ -256,16 +300,12 @@ const CHART_PAD_BOTTOM = 98; const CHART_PLOT_BOTTOM = CHART_HEIGHT - CHART_PAD_BOTTOM; const CHART_PLOT_HEIGHT = CHART_PLOT_BOTTOM - CHART_PAD_TOP; const CHART_X_TICK_Y = CHART_PLOT_BOTTOM + 18; -const COLORS = [ - "#2563eb", - "#16a34a", - "#dc2626", - "#9333ea", - "#ca8a04", - "#0891b2", - "#db2777", - "#475569", -]; +const TOOLTIP_EDGE_GAP = 12; +const TOOLTIP_CURSOR_GAP = 14; +const TOOLTIP_WIDTH = 240; +const TOOLTIP_MAX_HEIGHT = 360; +const TOOLTIP_HEADER_HEIGHT = 30; +const TOOLTIP_ROW_HEIGHT = 22; const presets = [ { label: "1d", seconds: 24 * 60 * 60 }, @@ -282,11 +322,29 @@ type SpeedLine = { key: string; label: string; metric: SpeedMetric; + protocol: string; color: string; path: string; values: number[]; }; +type SpeedHoverPoint = { + key: string; + label: string; + color: string; + value: number; + y: number; +}; + +type SpeedHover = { + index: number; + x: number; + label: string; + points: SpeedHoverPoint[]; + clientX: number; + clientY: number; +}; + const nowSeconds = () => Math.floor(Date.now() / 1000); const startAt = ref(nowSeconds() - presets[2].seconds); const endAt = ref(nowSeconds()); @@ -301,6 +359,7 @@ const expandedProtocols = ref(false); const splitMode = ref("overall"); const seriesMode = ref("both"); const showTickLabels = ref(true); +const speedHover = ref(null); let loadStatsId = 0; const chartWidth = CHART_WIDTH; @@ -332,20 +391,6 @@ const protocolLabel = (protocol: string) => { return labels[protocol] || protocol.toUpperCase(); }; -const colorForProtocol = (protocol: string) => { - if (protocol === "overall") { - return "#14b8a6"; - } - if (protocol === "other") { - return "#64748b"; - } - let hash = 0; - for (const char of protocol) { - hash = (hash * 31 + char.charCodeAt(0)) >>> 0; - } - return COLORS[hash % COLORS.length]; -}; - const totalReceived = computed(() => (stats.value?.protocolTotals || []).reduce( (sum, item) => sum + Number(item.receivedBytes || 0), @@ -380,6 +425,44 @@ const legendProtocols = computed(() => { return protocols; }); +const protocolColorOrder = computed(() => + [...new Set(visibleProtocols.value)].sort((left, right) => + left.localeCompare(right), + ), +); + +const protocolHue = (protocol: string) => { + const protocols = protocolColorOrder.value; + const index = protocols.indexOf(protocol); + if (index >= 0) { + return (210 + (index * 360) / Math.max(1, protocols.length)) % 360; + } + + let hash = 0; + for (const char of protocol) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return hash % 360; +}; + +const colorForProtocol = ( + protocol: string, + metric: SpeedMetric = "download", +) => { + if (protocol === "overall") { + return metric === "upload" ? "#fb923c" : "#14b8a6"; + } + if (protocol === "other") { + return metric === "upload" ? "#94a3b8" : "#64748b"; + } + const hue = Number(protocolHue(protocol).toFixed(3)); + const lightness = metric === "upload" ? 62 : 43; + return `hsl(${hue}, 72%, ${lightness}%)`; +}; + +const colorForSpeedLine = (protocol: string, metric: SpeedMetric) => + colorForProtocol(protocol, metric); + const monthlySegments = (month: MonthlyProtocolTotal) => { const protocolMap = new Map( month.protocols.map((item) => [item.protocol, item.receivedBytes]), @@ -518,7 +601,8 @@ const speedLines = computed(() => { key: `${protocol}:${metric}`, label: `${protocolLabel(protocol)} ${metric === "download" ? "Down" : "Up"}`, metric, - color: metric === "upload" ? "#f97316" : colorForProtocol(protocol), + protocol, + color: colorForSpeedLine(protocol, metric), values: points.map((point) => pointValue(point, protocol, metric)), path: "", })), @@ -529,6 +613,105 @@ const speedLines = computed(() => { .map((line) => ({ ...line, path: makePath(line.values, max) })); }); +const speedTooltipStyle = computed(() => { + const hover = speedHover.value; + if (!hover) { + return {}; + } + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const estimatedHeight = Math.min( + TOOLTIP_MAX_HEIGHT, + TOOLTIP_HEADER_HEIGHT + hover.points.length * TOOLTIP_ROW_HEIGHT, + ); + const preferredLeft = hover.clientX + TOOLTIP_CURSOR_GAP; + const fallbackLeft = hover.clientX - TOOLTIP_WIDTH - TOOLTIP_CURSOR_GAP; + const left = Math.min( + Math.max( + TOOLTIP_EDGE_GAP, + preferredLeft + TOOLTIP_WIDTH + TOOLTIP_EDGE_GAP <= viewportWidth + ? preferredLeft + : fallbackLeft, + ), + Math.max( + TOOLTIP_EDGE_GAP, + viewportWidth - TOOLTIP_WIDTH - TOOLTIP_EDGE_GAP, + ), + ); + const preferredTop = hover.clientY + TOOLTIP_CURSOR_GAP; + const fallbackTop = hover.clientY - estimatedHeight - TOOLTIP_CURSOR_GAP; + const top = Math.min( + Math.max( + TOOLTIP_EDGE_GAP, + preferredTop + estimatedHeight + TOOLTIP_EDGE_GAP <= viewportHeight + ? preferredTop + : fallbackTop, + ), + Math.max( + TOOLTIP_EDGE_GAP, + viewportHeight - estimatedHeight - TOOLTIP_EDGE_GAP, + ), + ); + return { + left: `${left}px`, + top: `${top}px`, + }; +}); + +function clearSpeedHover() { + speedHover.value = null; +} + +function onSpeedChartMove(event: MouseEvent) { + const points = stats.value?.speed || []; + const lines = speedLines.value; + if (!points.length || !lines.length) { + clearSpeedHover(); + return; + } + const target = event.currentTarget as HTMLElement | null; + if (!target) { + return; + } + const rect = target.getBoundingClientRect(); + if (rect.width <= 0) { + clearSpeedHover(); + return; + } + const plotWidth = CHART_WIDTH - CHART_PAD_X - CHART_PAD_RIGHT; + const relX = ((event.clientX - rect.left) / rect.width) * CHART_WIDTH; + const clamped = Math.min( + CHART_WIDTH - CHART_PAD_RIGHT, + Math.max(CHART_PAD_X, relX), + ); + const ratio = plotWidth <= 0 ? 0 : (clamped - CHART_PAD_X) / plotWidth; + const index = Math.round(ratio * (points.length - 1)); + const max = speedMax.value; + const bottom = CHART_PLOT_BOTTOM; + const hoverPoints = lines + .map((line) => { + const value = line.values[index] || 0; + return { + key: line.key, + label: line.label, + color: line.color, + value, + y: bottom - (value / max) * CHART_PLOT_HEIGHT, + }; + }) + .sort((a, b) => b.value - a.value); + speedHover.value = { + index, + x: + CHART_PAD_X + + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth), + label: formatTimeTick(points[index].minute), + points: hoverPoints, + clientX: event.clientX, + clientY: event.clientY, + }; +} + async function loadStats() { const loadId = ++loadStatsId; loading.value = true; @@ -567,6 +750,7 @@ function applyPreset(seconds: number) { } watch([startAt, endAt], loadStats, { immediate: true }); +watch([splitMode, seriesMode, expandedProtocols], clearSpeedHover);