#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "M175 + M178: BEP 17/19 web seed — chunk offsets bounded by Lengths; \
throttle rate calc widens u64 byte counters and elapsed seconds to f64 \
for division (precision loss intentional for display rates)"
)]
use std::time::{Duration, Instant};
use bytes::Bytes;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use irontide_core::{Id20, Lengths};
use irontide_storage::file_map::FileMap;
use crate::types::PeerEvent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WebSeedRequest {
pub url: String,
pub range_start: u64,
pub range_end: u64,
pub piece_offset: u32,
pub length: u32,
}
#[derive(Debug)]
pub(crate) enum WebSeedCommand {
FetchPiece(u32),
Shutdown,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum WebSeedError {
#[error("HTTP error: {0}")]
Http(String),
#[error("HTTP status {0}")]
HttpStatus(u16),
#[error("length mismatch: expected {expected}, got {got}")]
LengthMismatch { expected: u32, got: u32 },
#[error("retry after {0} seconds")]
RetryAfter(u64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WebSeedMode {
GetRight,
Hoffman,
}
#[derive(Debug, Clone)]
pub(crate) struct WebSeedUrlBuilder {
base_url: String,
single_file: bool,
torrent_name: String,
file_paths: Vec<String>,
}
impl WebSeedUrlBuilder {
pub fn single(base_url: String, file_name: String) -> Self {
Self {
base_url,
single_file: true,
torrent_name: file_name,
file_paths: Vec::new(),
}
}
pub fn multi(base_url: String, torrent_name: String, file_paths: Vec<String>) -> Self {
Self {
base_url,
single_file: false,
torrent_name,
file_paths,
}
}
pub fn requests_for_piece(
&self,
piece: u32,
lengths: &Lengths,
file_map: &FileMap,
) -> Vec<WebSeedRequest> {
if self.single_file {
self.requests_single_file(piece, lengths)
} else {
self.requests_multi_file(piece, lengths, file_map)
}
}
fn requests_single_file(&self, piece: u32, lengths: &Lengths) -> Vec<WebSeedRequest> {
let offset = lengths.piece_offset(piece);
let size = lengths.piece_size(piece);
if size == 0 {
return Vec::new();
}
let base = self.base_url.trim_end_matches('/');
let url = if base.ends_with(&format!("/{}", self.torrent_name)) || base == self.torrent_name
{
base.to_string()
} else {
format!("{base}/{}", self.torrent_name)
};
vec![WebSeedRequest {
url,
range_start: offset,
range_end: offset + u64::from(size) - 1,
piece_offset: 0,
length: size,
}]
}
fn requests_multi_file(
&self,
piece: u32,
_lengths: &Lengths,
file_map: &FileMap,
) -> Vec<WebSeedRequest> {
let segments = file_map.piece_segments(piece);
let base = self.base_url.trim_end_matches('/');
let mut piece_offset = 0u32;
let mut requests = Vec::with_capacity(segments.len());
for seg in &segments {
if seg.file_index >= self.file_paths.len() {
continue;
}
let file_path = &self.file_paths[seg.file_index];
let base_ends_with_name = base.ends_with(&format!("/{}", self.torrent_name));
let url = if base_ends_with_name {
format!("{base}/{file_path}")
} else {
format!("{base}/{}/{file_path}", self.torrent_name)
};
requests.push(WebSeedRequest {
url,
range_start: seg.file_offset,
range_end: seg.file_offset + u64::from(seg.len) - 1,
piece_offset,
length: seg.len,
});
piece_offset += seg.len;
}
requests
}
}
pub(crate) struct WebSeedTask {
url: String,
mode: WebSeedMode,
url_builder: WebSeedUrlBuilder,
lengths: Lengths,
file_map: FileMap,
info_hash: Id20,
http_client: reqwest::Client,
cmd_rx: mpsc::Receiver<WebSeedCommand>,
event_tx: mpsc::Sender<PeerEvent>,
throttle_ms: u64,
total_downloaded: u64,
last_emit: Option<Instant>,
last_emit_total: u64,
backoff_attempt: u32,
retry_base_secs: u64,
retry_factor: u64,
retry_cap_secs: u64,
max_failures: u32,
}
impl WebSeedTask {
#[allow(clippy::too_many_arguments)]
pub fn new(
url: String,
mode: WebSeedMode,
url_builder: WebSeedUrlBuilder,
lengths: Lengths,
file_map: FileMap,
info_hash: Id20,
cmd_rx: mpsc::Receiver<WebSeedCommand>,
event_tx: mpsc::Sender<PeerEvent>,
security: crate::url_guard::UrlSecurityConfig,
throttle_ms: u64,
initial_downloaded: u64,
retry_base_secs: u64,
retry_factor: u64,
retry_cap_secs: u64,
max_failures: u32,
) -> Self {
let http_client = crate::url_guard::build_http_client(security, None, "Torrent/0.60.0");
Self {
url,
mode,
url_builder,
lengths,
file_map,
info_hash,
http_client,
cmd_rx,
event_tx,
throttle_ms,
total_downloaded: initial_downloaded,
last_emit: None,
last_emit_total: initial_downloaded,
backoff_attempt: 0,
retry_base_secs,
retry_factor,
retry_cap_secs,
max_failures,
}
}
pub async fn run(mut self) {
debug!(url = %self.url, "web seed task started");
loop {
let Some(cmd) = self.cmd_rx.recv().await else {
break;
};
match cmd {
WebSeedCommand::FetchPiece(piece) => {
let result =
tokio::time::timeout(Duration::from_mins(1), self.download_piece(piece))
.await;
match result {
Ok(Ok(data)) => {
let len = data.len() as u64;
let _ = self
.event_tx
.send(PeerEvent::WebSeedPieceData {
url: self.url.clone(),
index: piece,
data: Bytes::from(data),
})
.await;
self.note_success_and_maybe_emit_progress(len).await;
self.backoff_attempt = 0;
}
Ok(Err(e)) => {
warn!(url = %self.url, piece, error = %e, "web seed piece download failed");
let message = e.to_string();
let _ = self
.event_tx
.send(PeerEvent::WebSeedError {
url: self.url.clone(),
piece,
message: message.clone(),
})
.await;
self.emit_error_progress(message).await;
let retry_after_floor = match &e {
WebSeedError::RetryAfter(secs) => Duration::from_secs(*secs),
_ => Duration::ZERO,
};
if !self.enter_backoff(retry_after_floor).await {
return;
}
}
Err(_) => {
warn!(url = %self.url, piece, "web seed piece download timed out");
let _ = self
.event_tx
.send(PeerEvent::WebSeedError {
url: self.url.clone(),
piece,
message: "timeout".into(),
})
.await;
self.emit_error_progress("timeout".into()).await;
if !self.enter_backoff(Duration::ZERO).await {
return;
}
}
}
}
WebSeedCommand::Shutdown => {
debug!(url = %self.url, "web seed task shutting down");
return;
}
}
}
}
async fn enter_backoff(&mut self, retry_after_floor: Duration) -> bool {
self.backoff_attempt = self.backoff_attempt.saturating_add(1);
if self.backoff_attempt >= self.max_failures {
let _ = self
.event_tx
.send(PeerEvent::WebSeedPermanentFailure {
url: self.url.clone(),
})
.await;
return false;
}
let backoff = self.compute_backoff().max(retry_after_floor);
debug!(url = %self.url, attempt = self.backoff_attempt, ?backoff, "web seed entering backoff");
let woke_from_sleep = tokio::select! {
() = tokio::time::sleep(backoff) => true,
cmd = self.cmd_rx.recv() => {
match cmd {
Some(WebSeedCommand::Shutdown) | None => return false,
Some(WebSeedCommand::FetchPiece(_)) => false,
}
}
};
if woke_from_sleep {
while let Ok(cmd) = self.cmd_rx.try_recv() {
if matches!(cmd, WebSeedCommand::Shutdown) {
return false;
}
}
let _ = self
.event_tx
.send(PeerEvent::WebSeedRetryReady {
url: self.url.clone(),
})
.await;
}
true
}
fn compute_backoff(&self) -> Duration {
let exp = self.backoff_attempt.saturating_sub(1);
let secs = self
.retry_base_secs
.saturating_mul(self.retry_factor.saturating_pow(exp));
Duration::from_secs(secs.min(self.retry_cap_secs))
}
async fn note_success_and_maybe_emit_progress(&mut self, bytes: u64) {
self.total_downloaded = self.total_downloaded.saturating_add(bytes);
let now = Instant::now();
let should_emit = match self.last_emit {
None => true, Some(last) => {
let elapsed_ms = now.duration_since(last).as_millis() as u64;
elapsed_ms >= self.throttle_ms
}
};
if !should_emit {
return;
}
let rate_bps = match self.last_emit {
Some(last) => {
let elapsed_secs = now.duration_since(last).as_secs_f64().max(0.001);
let bytes_window = self.total_downloaded.saturating_sub(self.last_emit_total);
(bytes_window as f64 / elapsed_secs) as u64
}
None => 0,
};
let _ = self
.event_tx
.send(PeerEvent::WebSeedProgress {
url: self.url.clone(),
bytes: self.total_downloaded,
rate_bps,
error: None,
})
.await;
self.last_emit = Some(now);
self.last_emit_total = self.total_downloaded;
}
async fn emit_error_progress(&mut self, message: String) {
let _ = self
.event_tx
.send(PeerEvent::WebSeedProgress {
url: self.url.clone(),
bytes: self.total_downloaded,
rate_bps: 0,
error: Some(message),
})
.await;
self.last_emit = Some(Instant::now());
self.last_emit_total = self.total_downloaded;
}
async fn download_piece(&self, piece: u32) -> Result<Vec<u8>, WebSeedError> {
match self.mode {
WebSeedMode::GetRight => self.download_piece_bep19(piece).await,
WebSeedMode::Hoffman => self.download_piece_bep17(piece).await,
}
}
async fn download_piece_bep19(&self, piece: u32) -> Result<Vec<u8>, WebSeedError> {
let piece_size = self.lengths.piece_size(piece) as usize;
let requests = self
.url_builder
.requests_for_piece(piece, &self.lengths, &self.file_map);
let mut buf = vec![0u8; piece_size];
for req in &requests {
let range = format!("bytes={}-{}", req.range_start, req.range_end);
let response = self
.http_client
.get(&req.url)
.header("Range", &range)
.send()
.await
.map_err(|e| WebSeedError::Http(e.to_string()))?;
let status = response.status().as_u16();
if status != 200 && status != 206 {
return Err(WebSeedError::HttpStatus(status));
}
let body = response
.bytes()
.await
.map_err(|e| WebSeedError::Http(e.to_string()))?;
if body.len() != req.length as usize {
return Err(WebSeedError::LengthMismatch {
expected: req.length,
got: body.len() as u32,
});
}
let start = req.piece_offset as usize;
let end = start + req.length as usize;
buf[start..end].copy_from_slice(&body);
}
Ok(buf)
}
async fn download_piece_bep17(&self, piece: u32) -> Result<Vec<u8>, WebSeedError> {
let piece_size = self.lengths.piece_size(piece);
let encoded_hash = url_encode_info_hash(&self.info_hash);
let url = format!(
"{}?info_hash={}&piece={}&ranges=0-{}",
self.url,
encoded_hash,
piece,
piece_size.saturating_sub(1),
);
let response = self
.http_client
.get(&url)
.send()
.await
.map_err(|e| WebSeedError::Http(e.to_string()))?;
let status = response.status().as_u16();
if status == 503 {
let body = response
.bytes()
.await
.map_err(|e| WebSeedError::Http(e.to_string()))?;
let secs = std::str::from_utf8(&body)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(60);
return Err(WebSeedError::RetryAfter(secs));
}
if status != 200 {
return Err(WebSeedError::HttpStatus(status));
}
let body = response
.bytes()
.await
.map_err(|e| WebSeedError::Http(e.to_string()))?;
if body.len() != piece_size as usize {
return Err(WebSeedError::LengthMismatch {
expected: piece_size,
got: body.len() as u32,
});
}
Ok(body.to_vec())
}
}
pub(crate) fn url_encode_info_hash(hash: &Id20) -> String {
use std::fmt::Write;
let bytes = hash.as_bytes();
let mut encoded = String::with_capacity(bytes.len() * 3);
for &b in bytes {
let _ = write!(encoded, "%{b:02X}");
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
use irontide_core::Lengths;
use irontide_storage::file_map::FileMap;
#[test]
fn url_builder_single_file_piece() {
let builder =
WebSeedUrlBuilder::single("http://example.com/files".into(), "movie.mkv".into());
let lengths = Lengths::new(1_048_576, 262_144, 16384);
let fm = FileMap::new(vec![1_048_576], lengths.clone());
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].url, "http://example.com/files/movie.mkv");
assert_eq!(reqs[0].range_start, 0);
assert_eq!(reqs[0].range_end, 262_143);
assert_eq!(reqs[0].piece_offset, 0);
assert_eq!(reqs[0].length, 262_144);
}
#[test]
fn url_builder_single_file_last_piece() {
let builder =
WebSeedUrlBuilder::single("http://example.com/files".into(), "movie.mkv".into());
let lengths = Lengths::new(500_000, 262_144, 16384);
let fm = FileMap::new(vec![500_000], lengths.clone());
let reqs = builder.requests_for_piece(1, &lengths, &fm);
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].range_start, 262_144);
assert_eq!(reqs[0].length, 237_856);
assert_eq!(reqs[0].range_end, 499_999);
}
#[test]
fn url_builder_multi_file_no_span() {
let lengths = Lengths::new(524_288, 262_144, 16384);
let fm = FileMap::new(vec![262_144, 262_144], lengths.clone());
let builder = WebSeedUrlBuilder::multi(
"http://example.com".into(),
"torrent_dir".into(),
vec!["file1.txt".into(), "file2.txt".into()],
);
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].url, "http://example.com/torrent_dir/file1.txt");
assert_eq!(reqs[0].range_start, 0);
assert_eq!(reqs[0].range_end, 262_143);
let reqs = builder.requests_for_piece(1, &lengths, &fm);
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0].url, "http://example.com/torrent_dir/file2.txt");
assert_eq!(reqs[0].range_start, 0);
}
#[test]
fn url_builder_multi_file_span_two() {
let lengths = Lengths::new(300, 300, 16384);
let fm = FileMap::new(vec![100, 200], lengths.clone());
let builder = WebSeedUrlBuilder::multi(
"http://example.com".into(),
"dir".into(),
vec!["a.bin".into(), "b.bin".into()],
);
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(reqs.len(), 2);
assert_eq!(reqs[0].url, "http://example.com/dir/a.bin");
assert_eq!(reqs[0].range_start, 0);
assert_eq!(reqs[0].range_end, 99);
assert_eq!(reqs[0].piece_offset, 0);
assert_eq!(reqs[0].length, 100);
assert_eq!(reqs[1].url, "http://example.com/dir/b.bin");
assert_eq!(reqs[1].range_start, 0);
assert_eq!(reqs[1].range_end, 199);
assert_eq!(reqs[1].piece_offset, 100);
assert_eq!(reqs[1].length, 200);
}
#[test]
fn url_builder_trailing_slash() {
let builder =
WebSeedUrlBuilder::single("http://example.com/files/".into(), "test.bin".into());
let lengths = Lengths::new(100, 100, 16384);
let fm = FileMap::new(vec![100], lengths.clone());
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(reqs[0].url, "http://example.com/files/test.bin");
}
#[test]
fn url_builder_single_file_base_already_ends_with_name() {
let name = "debian-13.4.0-amd64-DVD-1.iso";
let full =
format!("https://cdimage.debian.org/cdimage/release/13.4.0/amd64/iso-dvd/{name}");
let builder = WebSeedUrlBuilder::single(full.clone(), name.to_string());
let lengths = Lengths::new(1024, 1024, 16384);
let fm = FileMap::new(vec![1024], lengths.clone());
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(
reqs[0].url, full,
"url must match the url-list base — no double-suffix"
);
}
#[test]
fn url_builder_single_file_base_equals_name() {
let builder = WebSeedUrlBuilder::single("file.bin".into(), "file.bin".into());
let lengths = Lengths::new(100, 100, 16384);
let fm = FileMap::new(vec![100], lengths.clone());
let reqs = builder.requests_for_piece(0, &lengths, &fm);
assert_eq!(reqs[0].url, "file.bin");
}
#[test]
fn url_encode_info_hash_format() {
let hash = Id20::from([
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x00, 0xFF, 0x01, 0x23, 0x45, 0x67,
0x89, 0xAB, 0xCD, 0xEF, 0x00, 0xFF,
]);
let encoded = url_encode_info_hash(&hash);
assert_eq!(
encoded,
"%01%23%45%67%89%AB%CD%EF%00%FF%01%23%45%67%89%AB%CD%EF%00%FF"
);
}
#[test]
fn bep17_url_construction() {
let hash = Id20::from([0xAA; 20]);
let encoded = url_encode_info_hash(&hash);
let base = "http://seed.example.com/seed";
let piece = 5u32;
let piece_size = 262_143_u32;
let url = format!("{base}?info_hash={encoded}&piece={piece}&ranges=0-{piece_size}");
assert!(url.starts_with("http://seed.example.com/seed?info_hash=%AA"));
assert!(url.contains("&piece=5&"));
assert!(url.ends_with("&ranges=0-262143"));
}
#[test]
fn web_seed_url_validation_global_passes() {
let cfg = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: true,
allow_idna: false,
validate_https_trackers: true,
};
assert!(
crate::url_guard::validate_web_seed_url("http://cdn.example.com/files/torrent/", cfg)
.is_ok()
);
}
fn make_throttle_task(
throttle_ms: u64,
initial_downloaded: u64,
) -> (WebSeedTask, mpsc::Receiver<PeerEvent>) {
let (event_tx, event_rx) = mpsc::channel(32);
let (_cmd_tx, cmd_rx) = mpsc::channel(8);
let lengths = Lengths::new(1024, 1024, 16384);
let fm = FileMap::new(vec![1024], lengths.clone());
let security = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: false,
allow_idna: true,
validate_https_trackers: false,
};
let task = WebSeedTask::new(
"http://example.com/file".into(),
WebSeedMode::GetRight,
WebSeedUrlBuilder::single("http://example.com/".into(), "file".into()),
lengths,
fm,
Id20([0u8; 20]),
cmd_rx,
event_tx,
security,
throttle_ms,
initial_downloaded,
10,
6,
3600,
10,
);
(task, event_rx)
}
fn drain_progress(rx: &mut mpsc::Receiver<PeerEvent>) -> Vec<(u64, u64, Option<String>)> {
let mut out = Vec::new();
while let Ok(evt) = rx.try_recv() {
if let PeerEvent::WebSeedProgress {
bytes,
rate_bps,
error,
..
} = evt
{
out.push((bytes, rate_bps, error));
}
}
out
}
#[tokio::test]
async fn cold_start_emits_immediately_even_with_throttle() {
let (mut task, mut rx) = make_throttle_task(250, 0);
task.note_success_and_maybe_emit_progress(1024).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 1);
assert_eq!(events[0].0, 1024); assert_eq!(events[0].2, None); }
#[tokio::test]
async fn within_throttle_window_coalesces() {
let (mut task, mut rx) = make_throttle_task(250, 0);
task.note_success_and_maybe_emit_progress(500).await;
task.note_success_and_maybe_emit_progress(500).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 1, "second emission should be coalesced");
assert_eq!(events[0].0, 500);
}
#[tokio::test]
async fn throttle_zero_disables_coalescing() {
let (mut task, mut rx) = make_throttle_task(0, 0);
task.note_success_and_maybe_emit_progress(100).await;
task.note_success_and_maybe_emit_progress(100).await;
task.note_success_and_maybe_emit_progress(100).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 3, "throttle=0 should emit on every chunk");
assert_eq!(events[0].0, 100);
assert_eq!(events[1].0, 200);
assert_eq!(events[2].0, 300);
}
#[tokio::test]
async fn after_window_elapses_next_chunk_emits() {
let (mut task, mut rx) = make_throttle_task(50, 0);
task.note_success_and_maybe_emit_progress(100).await;
tokio::time::sleep(Duration::from_millis(80)).await;
task.note_success_and_maybe_emit_progress(200).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 2);
assert_eq!(events[0].0, 100);
assert_eq!(events[1].0, 300);
}
#[tokio::test]
async fn errors_bypass_throttle() {
let (mut task, mut rx) = make_throttle_task(10_000, 0);
task.note_success_and_maybe_emit_progress(500).await;
task.emit_error_progress("boom".into()).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 2);
assert_eq!(events[1].2, Some("boom".to_string()));
}
#[tokio::test]
async fn initial_downloaded_seeds_total() {
let (mut task, mut rx) = make_throttle_task(0, 1_000_000);
task.note_success_and_maybe_emit_progress(50).await;
let events = drain_progress(&mut rx);
assert_eq!(events.len(), 1);
assert_eq!(
events[0].0, 1_000_050,
"stats should resume from initial_downloaded"
);
}
#[test]
fn web_seed_url_validation_local_query_rejected() {
let cfg = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: true,
allow_idna: false,
validate_https_trackers: true,
};
assert!(
crate::url_guard::validate_web_seed_url("http://192.168.1.100/files/?secret=abc", cfg,)
.is_err()
);
}
#[test]
fn compute_backoff_exponential_sequence() {
let (mut task, _rx) = make_throttle_task(0, 0);
task.backoff_attempt = 1;
assert_eq!(task.compute_backoff(), Duration::from_secs(10));
task.backoff_attempt = 2;
assert_eq!(task.compute_backoff(), Duration::from_mins(1));
task.backoff_attempt = 3;
assert_eq!(task.compute_backoff(), Duration::from_mins(6));
task.backoff_attempt = 4;
assert_eq!(task.compute_backoff(), Duration::from_mins(36));
task.backoff_attempt = 5;
assert_eq!(task.compute_backoff(), Duration::from_hours(1));
}
#[test]
fn compute_backoff_with_retry_after_floor() {
let (mut task, _rx) = make_throttle_task(0, 0);
task.backoff_attempt = 1;
let computed = task.compute_backoff();
let floor = Duration::from_mins(5);
let effective = computed.max(floor);
assert_eq!(effective, Duration::from_mins(5));
}
#[tokio::test]
async fn backoff_resets_on_success() {
let (mut task, _rx) = make_throttle_task(0, 0);
task.backoff_attempt = 5;
task.note_success_and_maybe_emit_progress(100).await;
task.backoff_attempt = 0;
assert_eq!(task.backoff_attempt, 0);
}
#[tokio::test]
async fn permanent_failure_after_max_failures() {
let (event_tx, mut event_rx) = mpsc::channel(32);
let (_cmd_tx, cmd_rx) = mpsc::channel(8);
let lengths = Lengths::new(1024, 1024, 16384);
let fm = FileMap::new(vec![1024], lengths.clone());
let security = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: false,
allow_idna: true,
validate_https_trackers: false,
};
let mut task = WebSeedTask::new(
"http://example.com/file".into(),
WebSeedMode::GetRight,
WebSeedUrlBuilder::single("http://example.com/".into(), "file".into()),
lengths,
fm,
Id20([0u8; 20]),
cmd_rx,
event_tx,
security,
0,
0,
10,
6,
3600,
3, );
task.backoff_attempt = 2; let cont = task.enter_backoff(Duration::ZERO).await;
assert!(!cont);
let evt = event_rx.try_recv().unwrap();
assert!(matches!(evt, PeerEvent::WebSeedPermanentFailure { .. }));
}
#[tokio::test]
async fn shutdown_during_backoff_exits() {
let (event_tx, _event_rx) = mpsc::channel(32);
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let lengths = Lengths::new(1024, 1024, 16384);
let fm = FileMap::new(vec![1024], lengths.clone());
let security = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: false,
allow_idna: true,
validate_https_trackers: false,
};
let mut task = WebSeedTask::new(
"http://example.com/file".into(),
WebSeedMode::GetRight,
WebSeedUrlBuilder::single("http://example.com/".into(), "file".into()),
lengths,
fm,
Id20([0u8; 20]),
cmd_rx,
event_tx,
security,
0,
0,
10,
6,
3600,
10,
);
task.backoff_attempt = 0;
let _ = cmd_tx.send(WebSeedCommand::Shutdown).await;
let cont = task.enter_backoff(Duration::ZERO).await;
assert!(!cont);
}
#[tokio::test(start_paused = true)]
async fn channel_drained_on_wake() {
let (event_tx, mut event_rx) = mpsc::channel(32);
let (_cmd_tx, cmd_rx) = mpsc::channel(8);
let lengths = Lengths::new(1024, 1024, 16384);
let fm = FileMap::new(vec![1024], lengths.clone());
let security = crate::url_guard::UrlSecurityConfig {
ssrf_mitigation: false,
allow_idna: true,
validate_https_trackers: false,
};
let mut task = WebSeedTask::new(
"http://example.com/file".into(),
WebSeedMode::GetRight,
WebSeedUrlBuilder::single("http://example.com/".into(), "file".into()),
lengths,
fm,
Id20([0u8; 20]),
cmd_rx,
event_tx,
security,
0,
0,
1,
1,
1,
10,
);
task.backoff_attempt = 0;
let cont = task.enter_backoff(Duration::ZERO).await;
assert!(cont);
let evt = event_rx.try_recv().unwrap();
assert!(matches!(evt, PeerEvent::WebSeedRetryReady { .. }));
}
}